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"); }