Files
bunext/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts
T
tbenandClaude Opus 5.5 4d4a79a6dd Push HMR updates before the SSR rebuild in dev
Client-only edits are sent to the browser right after the hydration build,
without re-running server functions; SSR rebuilds afterwards and props are
re-fetched only when a .server.ts output changed or after fullRebuild. Page
requests wait for an in-flight SSR rebuild. The SSR build no longer runs
Tailwind, the PostCSS processor is reused across rebuilds, and the watcher
debounce drops from 200ms to 50ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:32:33 +01:00

252 lines
9.5 KiB
TypeScript

import { type Plugin } from "esbuild";
import type { PageFiles } from "../../../types";
import { log } from "../../../utils/log";
import grabArtifactsFromBundledResults from "../grab-artifacts-from-bundled-result";
import _ from "lodash";
import enqueueSSRRebuild from "../pages-ssr-context-bundler";
import grabDirNames from "../../../utils/grab-dir-names";
import {
logBuildErrorsOnce,
resetBuildErrorLog,
} from "../../../utils/build-error-log";
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
import fullRebuild from "../../server/full-rebuild";
import path from "path";
import cleanupLogsDirs from "../../cleanup-logs-dir";
import grabRelativePath from "../../../utils/grab-relative-path";
import cleanupArtifacts from "../../server/cleanup-artifacts";
import { pushClientUpdate } from "../../server/server-post-build-fn";
const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames();
let build_start = 0;
type Params = {
entryToPage: Map<
string,
PageFiles & {
tsx: string;
}
>;
post_build_fn?: (params: {
artifacts: any[];
changed_pages?: string[];
}) => Promise<void> | void;
build_only?: boolean;
/**
* Always re-fetch server props after this context's first build, even
* when only client code changed (set by `fullRebuild`, e.g. after a
* config change).
*/
refresh_props_on_first_build?: boolean;
};
export default function esbuildCTXArtifactTracker({
entryToPage,
post_build_fn,
build_only,
refresh_props_on_first_build,
}: Params) {
let force_props_refresh = Boolean(refresh_props_on_first_build);
const artifactTracker: Plugin = {
name: "artifact-tracker",
setup(build) {
build.onStart(async () => {
build_start = performance.now();
});
build.onEnd(async (result) => {
if (result.errors.length > 0) {
global.BUNEXT_BUILD_FAILED = true;
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
// Rebuild attempts repeat for every file edit / request
// while the error persists; only log the error once.
logBuildErrorsOnce(result.errors, (line) =>
log.error(` ${line}`),
);
for (
let i = global.BUNEXT_HMR_CONTROLLERS.length - 1;
i >= 0;
i--
) {
const controller = global.BUNEXT_HMR_CONTROLLERS[i];
try {
controller?.controller?.enqueue(
`event: update\ndata: ${JSON.stringify({ reload: true })}\n\n`,
);
} catch {
global.BUNEXT_HMR_CONTROLLERS.splice(i, 1);
}
}
return;
}
const artifacts = grabArtifactsFromBundledResults({
result,
entryToPage,
});
// Pages whose entry output hash changed since the previous
// build. With `splitting`, a change to any shared chunk also
// re-hashes every page entry that imports it, so this set
// covers all pages whose client graph actually changed.
const changed_pages: string[] = [];
if (artifacts?.[0] && artifacts.length > 0) {
for (let i = 0; i < artifacts.length; i++) {
const artifact = artifacts[i];
if (
artifact?.local_path &&
global.BUNEXT_BUNDLER_CTX_MAP
) {
const relative_path = grabRelativePath(
artifact.local_path,
);
const prior =
global.BUNEXT_BUNDLER_CTX_MAP[relative_path];
if (
!prior?.path ||
prior.path !== artifact.path ||
prior.css_path !== artifact.css_path
) {
changed_pages.push(relative_path);
}
global.BUNEXT_BUNDLER_CTX_MAP[relative_path] =
_.merge(prior, {
...artifact,
local_path: relative_path,
});
}
}
}
const elapsed = (performance.now() - build_start).toFixed(0);
log.success(`[Built] in ${elapsed}ms`);
// A successful build clears the "last error" so a NEW incident
// with the same signature is reported again.
resetBuildErrorLog();
if (result.warnings.length > 0) {
logBuildErrorsOnce(result.warnings, (line) =>
log.warn(line),
);
}
global.BUNEXT_BUILD_FAILED = false;
global.BUNEXT_BUNDLER_CTX_DISPOSED = false;
const props_refresh_forced = force_props_refresh;
force_props_refresh = false;
const does_error_file_exist = existsSync(
BUNX_BUNDLER_ERROR_EXIT_FILE,
);
// Client-only changes are pushed before the SSR rebuild;
// anything that may change server props waits for SSR so
// props are fresh. The rebuild is single-flighted: concurrent hydration
// rebuilds join the in-flight SSR rebuild instead of
// starting overlapping ones.
if (build_only) {
try {
await enqueueSSRRebuild();
} catch (error) {
log.error(`SSR Bundler Error: ${error}`);
}
} else if (does_error_file_exist) {
mkdirSync(BUNX_ERROR_LOGS_DIR, { recursive: true });
cpSync(
BUNX_BUNDLER_ERROR_EXIT_FILE,
path.join(BUNX_ERROR_LOGS_DIR, `${Date.now()}.log`),
);
rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true });
cleanupLogsDirs();
await fullRebuild();
} else if (
changed_pages.length > 0 &&
post_build_fn &&
!props_refresh_forced
) {
// Client graph changed: push the new bundle right away.
// Server functions live in `.server.ts` files, outside
// the client graph, so the browser's current props are
// still valid and it needn't wait for the SSR rebuild.
pushClientUpdate(changed_pages);
const server_hashes_before = grabServerEntryPaths();
try {
await enqueueSSRRebuild();
} catch (error) {
log.error(`SSR Bundler Error: ${error}`);
}
// A module shared by a page and its server file changed:
// props may differ, so re-fetch them for every tab.
if (server_hashes_before !== grabServerEntryPaths()) {
try {
await post_build_fn({
artifacts: artifacts || [],
changed_pages: [],
});
} catch (error) {
log.error(`Post-build Error: ${error}`);
}
}
} else {
try {
await enqueueSSRRebuild();
} catch (error) {
log.error(`SSR Bundler Error: ${error}`);
}
if (artifacts?.[0] && artifacts.length > 0) {
try {
await post_build_fn?.({
artifacts,
changed_pages,
});
} catch (error) {
log.error(`Post-build Error: ${error}`);
}
}
}
// Prune orphaned entry outputs from previous rebuilds.
await cleanupArtifacts({ paradigm: "hydration" });
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
});
},
};
return artifactTracker;
}
/**
* Fingerprint of the SSR outputs of all `.server.ts(x)` entries. Outputs are
* content-hashed, so a changed fingerprint means some server function's code
* (or a module it imports) changed.
*/
function grabServerEntryPaths(): string {
const map = global.BUNEXT_SSR_BUNDLER_CTX_MAP;
if (!map) return "";
return Object.keys(map)
.filter((key) => key.match(/\.server\.tsx?$/))
.sort()
.map((key) => `${key}:${map[key]?.path || ""}`)
.join("\n");
}