Files
bunext/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js
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

178 lines
8.5 KiB
JavaScript

import {} from "esbuild";
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;
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }) {
let force_props_refresh = Boolean(refresh_props_on_first_build);
const artifactTracker = {
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 = [];
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() {
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");
}