diff --git a/dist/commands/build/index.js b/dist/commands/build/index.js index 1e2129a..df251be 100644 --- a/dist/commands/build/index.js +++ b/dist/commands/build/index.js @@ -3,7 +3,6 @@ import { log } from "../../utils/log"; import grabDirNames from "../../utils/grab-dir-names"; import { rmSync, writeFileSync } from "fs"; import bunextInit from "../../functions/bunext-init"; -import pagesSSRBundler from "../../functions/bundler/pages-ssr-bundler"; import { v4 } from "uuid"; const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON, BUNX_CWD_MODULE_CACHE_DIR, BUILD_INFO_JSON, } = grabDirNames(); export default function () { @@ -21,7 +20,6 @@ export default function () { catch (error) { } global.BUNEXT_SKIPPED_BROWSER_MODULES = new Set(); await bunextInit({ build_only: true }); - // await pagesSSRBundler(); // A failed bundle must fail the command: CI and scripts rely on // the exit code instead of grepping logs. if (global.BUNEXT_BUILD_FAILED) { diff --git a/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts b/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts index c751412..ad5cc8f 100644 --- a/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts +++ b/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts @@ -2,6 +2,7 @@ import type { BundlerCTXMap } from "../../types"; type Params = { post_build_fn?: (params: { artifacts: BundlerCTXMap[]; + changed_pages?: string[]; }) => Promise | void; build_only?: boolean; start?: boolean; diff --git a/dist/functions/bundler/all-pages-esbuild-context-bundler.js b/dist/functions/bundler/all-pages-esbuild-context-bundler.js index b4c8990..4d2653e 100644 --- a/dist/functions/bundler/all-pages-esbuild-context-bundler.js +++ b/dist/functions/bundler/all-pages-esbuild-context-bundler.js @@ -9,9 +9,23 @@ import virtualFilesPlugin from "./plugins/virtual-files-plugin"; import esbuildCTXArtifactTracker from "./plugins/esbuild-ctx-artifact-tracker"; import { logBuildErrorsOnce, } from "../../utils/build-error-log"; import { log } from "../../utils/log"; +import grabRootFilePath from "../server/web-pages/grab-root-file-path"; const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames(); +// Snapshot of the entry key set + root file presence of the last build. +// When it differs from the freshly computed set, the esbuild context must +// be recreated (entry points are fixed at context creation). +let last_entry_keys = undefined; +let last_root_file_path = undefined; +function arraysEqual(a, b) { + if (a.length !== b.length) + return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) + return false; + } + return true; +} export default async function allPagesESBuildContextBundler(params) { - const _t0 = Date.now(); try { const pages = grabAllPages({ exclude_api: true }); global.BUNEXT_PAGE_FILES = pages; @@ -25,53 +39,65 @@ export default async function allPagesESBuildContextBundler(params) { continue; } const entryFile = path.join(BUNX_HYDRATION_SRC_DIR, `${page.url_path}.tsx`); - // await Bun.write(entryFile, txt, { createPath: true }); entryToPage.set(entryFile, { ...page, tsx }); } const entryPoints = [...entryToPage.keys()].map((e) => `hydration-virtual:${e}`); - // This is a brand-new context: wipe the previous context's failure - // history so the "2 failed starts => dispose" guard starts fresh. - global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0; - global.BUNEXT_BUNDLER_CTX = await esbuild.context({ - entryPoints, - outdir: HYDRATION_DST_DIR, - bundle: true, - minify: !dev, - format: "esm", - target: "es2020", - platform: "browser", - define: { - "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), - }, - entryNames: "[dir]/[hash]", - metafile: true, - plugins: [ - forceExternalReact(), - tailwindEsbuildPlugin, - virtualFilesPlugin({ - entryToPage, - }), - esbuildCTXArtifactTracker({ - entryToPage, - post_build_fn: params?.post_build_fn, - build_only: params?.build_only || params?.start, - }), - ], - jsx: "automatic", - splitting: true, - treeShaking: true, - external: [ - "react", - "react-dom", - "react-dom/client", - "react/jsx-runtime", - "react/jsx-dev-runtime", - ...(global.BUNEXT_CONFIG.page_compiler_excludes || []), - ], - // esbuild's own error logging prints per attempt; the - // artifact-tracker plugin reports errors (deduped, once) itself. - logLevel: "silent", - }); + const entry_keys = [...entryToPage.keys()].sort(); + const { root_file_path } = grabRootFilePath(); + const structure_changed = !global.BUNEXT_BUNDLER_CTX || + global.BUNEXT_BUNDLER_CTX_DISPOSED || + !last_entry_keys || + !arraysEqual(last_entry_keys, entry_keys) || + last_root_file_path !== root_file_path; + if (structure_changed) { + try { + await global.BUNEXT_BUNDLER_CTX?.dispose(); + } + catch (error) { } + global.BUNEXT_BUNDLER_CTX = undefined; + global.BUNEXT_BUNDLER_CTX = await esbuild.context({ + entryPoints, + outdir: HYDRATION_DST_DIR, + bundle: true, + minify: !dev, + format: "esm", + target: "es2020", + platform: "browser", + define: { + "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), + }, + entryNames: "[dir]/[hash]", + metafile: true, + plugins: [ + forceExternalReact(), + tailwindEsbuildPlugin, + virtualFilesPlugin({ + entryToPage, + }), + esbuildCTXArtifactTracker({ + entryToPage, + post_build_fn: params?.post_build_fn, + build_only: params?.build_only || params?.start, + }), + ], + jsx: "automatic", + splitting: true, + treeShaking: true, + external: [ + "react", + "react-dom", + "react-dom/client", + "react/jsx-runtime", + "react/jsx-dev-runtime", + ...(global.BUNEXT_CONFIG.page_compiler_excludes || []), + ], + // esbuild's own error logging prints per attempt; the + // artifact-tracker plugin reports errors (deduped, once) itself. + logLevel: "silent", + }); + last_entry_keys = entry_keys; + last_root_file_path = root_file_path; + } await global.BUNEXT_BUNDLER_CTX.rebuild(); } catch (error) { diff --git a/dist/functions/bundler/pages-ssr-bundler.d.ts b/dist/functions/bundler/pages-ssr-bundler.d.ts deleted file mode 100644 index ace662f..0000000 --- a/dist/functions/bundler/pages-ssr-bundler.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -type Params = { - post_build_fn?: (params: { - artifacts: any[]; - }) => Promise | void; -}; -export default function pagesSSRBundler(params?: Params): Promise; -export {}; diff --git a/dist/functions/bundler/pages-ssr-bundler.js b/dist/functions/bundler/pages-ssr-bundler.js deleted file mode 100644 index ee51236..0000000 --- a/dist/functions/bundler/pages-ssr-bundler.js +++ /dev/null @@ -1,101 +0,0 @@ -import * as esbuild from "esbuild"; -import grabAllPages from "../../utils/grab-all-pages"; -import grabDirNames from "../../utils/grab-dir-names"; -import isDevelopment from "../../utils/is-development"; -import tailwindEsbuildPlugin from "../server/web-pages/tailwind-esbuild-plugin"; -import grabPageReactComponentString from "../server/web-pages/grab-page-react-component-string"; -import grabRootFilePath from "../server/web-pages/grab-root-file-path"; -import ssrVirtualFilesPlugin from "./plugins/ssr-virtual-files-plugin"; -import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker"; -import { writeFileSync } from "fs"; -import path from "path"; -import { log } from "../../utils/log"; -import { logBuildErrorsOnce, } from "../../utils/build-error-log"; -const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); -export default async function pagesSSRBundler(params) { - const pages = grabAllPages({ - include_server: true, - }); - const dev = isDevelopment(); - const config = global.BUNEXT_CONFIG; - try { - writeFileSync(path.join(BUNX_TMP_DIR, "ssr-pages.json"), JSON.stringify(pages, null, 4)); - } - catch (error) { } - const entryToPage = new Map(); - const { root_file_path } = grabRootFilePath(); - for (const page of pages) { - if (page.local_path.match(/\/pages\/api\//) || - page.local_path.match(/\.server\.tsx?$/)) { - const ts = await Bun.file(page.local_path).text(); - if (ts.match(/(export default)|(export \w+ handler)|(export \w+ server)/)) { - entryToPage.set(page.local_path, { ...page, tsx: ts }); - } - continue; - } - const tsx = grabPageReactComponentString({ - file_path: page.local_path, - root_file_path, - }); - if (!tsx) - continue; - if (!tsx.match(/export default/)) - continue; - entryToPage.set(page.local_path, { ...page, tsx }); - } - const entryPoints = [...entryToPage.keys()].map((e) => `ssr-virtual:${e}`); - try { - writeFileSync(path.join(BUNX_TMP_DIR, "ssr-entry-to-page.json"), JSON.stringify(Object(entryToPage), null, 4)); - writeFileSync(path.join(BUNX_TMP_DIR, "ssr-entrypoints.json"), JSON.stringify(entryPoints, null, 4)); - } - catch (error) { } - try { - await esbuild.build({ - entryPoints, - outdir: BUNX_CWD_MODULE_CACHE_DIR, - bundle: true, - minify: !dev, - format: "esm", - target: "esnext", - platform: "node", - define: { - "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), - }, - entryNames: "[dir]/[hash]", - metafile: true, - plugins: [ - tailwindEsbuildPlugin, - ssrVirtualFilesPlugin({ - entryToPage, - }), - ssrCTXArtifactTracker({ - entryToPage, - post_build_fn: params?.post_build_fn, - }), - ], - jsx: "automatic", - external: [ - "react", - "react-dom", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "bun:*", - "bun", - "sqlite-vec", - "better-sqlite3", - ...(config.ssr_compiler_excludes || []), - ], - splitting: true, - logLevel: "silent", - }); - } - catch (error) { - global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; - global.BUNEXT_BUILD_FAILED = true; - logBuildErrorsOnce([ - { - text: `SSR Bundler Error: ${error?.message || String(error)}`, - }, - ], (line) => log.error(line)); - } -} diff --git a/dist/functions/bundler/pages-ssr-context-bundler.d.ts b/dist/functions/bundler/pages-ssr-context-bundler.d.ts index e462db3..f76464c 100644 --- a/dist/functions/bundler/pages-ssr-context-bundler.d.ts +++ b/dist/functions/bundler/pages-ssr-context-bundler.d.ts @@ -1,7 +1,18 @@ -type Params = { - post_build_fn?: (params: { - artifacts: any[]; - }) => Promise | void; +type RebuildParams = { + /** + * Force a context recreation even when the entry set is unchanged. + * Used by `fullRebuild` after it disposed the contexts. + */ + force?: boolean; }; -export default function pagesSSRContextBundler(params?: Params): Promise; +/** + * Single-flight SSR rebuild. Concurrent callers (each successful hydration + * rebuild triggers this, plus watcher/structural paths) join the in-flight + * rebuild instead of spawning overlapping esbuild builds. + * + * A fresh content change that lands while a rebuild is in flight is picked + * up by the next hydration rebuild cycle (each watcher event re-runs the + * hydration context rebuild, which re-enters this queue). + */ +export default function enqueueSSRRebuild(params?: RebuildParams): Promise; export {}; diff --git a/dist/functions/bundler/pages-ssr-context-bundler.js b/dist/functions/bundler/pages-ssr-context-bundler.js index 7ffccac..a950c5a 100644 --- a/dist/functions/bundler/pages-ssr-context-bundler.js +++ b/dist/functions/bundler/pages-ssr-context-bundler.js @@ -7,20 +7,49 @@ import grabPageReactComponentString from "../server/web-pages/grab-page-react-co import grabRootFilePath from "../server/web-pages/grab-root-file-path"; import ssrVirtualFilesPlugin from "./plugins/ssr-virtual-files-plugin"; import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker"; -const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); -export default async function pagesSSRContextBundler(params) { - const pages = grabAllPages(); - const dev = isDevelopment(); - if (global.BUNEXT_SSR_BUNDLER_CTX) { - await global.BUNEXT_SSR_BUNDLER_CTX.dispose(); - global.BUNEXT_SSR_BUNDLER_CTX = undefined; +import cleanupArtifacts from "../server/cleanup-artifacts"; +import { writeFileSync } from "fs"; +import path from "path"; +import { log } from "../../utils/log"; +import { logBuildErrorsOnce, } from "../../utils/build-error-log"; +const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); +// Current virtual entry contents. The SSR virtual plugin resolves its +// onLoad contents lazily from this map on every rebuild, so edits to +// `.server.ts`/api files (whose entries mirror their raw file content) are +// picked up without recreating the esbuild context. +let current_entry_map = new Map(); +// Entry key set + root file presence of the last *successful* build. When +// this differs from the freshly computed set, the context must be +// recreated (esbuild cannot add/remove entry points on a live context). +let last_entry_keys = undefined; +let last_root_file_path = undefined; +let rebuild_in_flight; +function arraysEqual(a, b) { + if (a.length !== b.length) + return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) + return false; } - const entryToPage = new Map(); + return true; +} +async function computeEntryToPage() { + const pages = grabAllPages({ + include_server: true, + }); const { root_file_path } = grabRootFilePath(); + const entryToPage = new Map(); + try { + writeFileSync(path.join(BUNX_TMP_DIR, "ssr-pages.json"), JSON.stringify(pages, null, 4)); + } + catch (error) { } for (const page of pages) { - if (page.local_path.match(/\/pages\/api\//)) { + if (page.local_path.match(/\/pages\/api\//) || + page.local_path.match(/\.server\.tsx?$/)) { const ts = await Bun.file(page.local_path).text(); - entryToPage.set(page.local_path, { ...page, tsx: ts }); + if (ts.match(/(export default)|(export \w+ handler)|(export \w+ server)/)) { + entryToPage.set(page.local_path, { ...page, tsx: ts }); + } continue; } const tsx = grabPageReactComponentString({ @@ -29,41 +58,125 @@ export default async function pagesSSRContextBundler(params) { }); if (!tsx) continue; + if (!tsx.match(/export default/)) + continue; entryToPage.set(page.local_path, { ...page, tsx }); } - const entryPoints = [...entryToPage.keys()].map((e) => `ssr-virtual:${e}`); - global.BUNEXT_SSR_BUNDLER_CTX = await esbuild.context({ - entryPoints, - outdir: BUNX_CWD_MODULE_CACHE_DIR, - bundle: true, - minify: !dev, - format: "esm", - target: "esnext", - platform: "node", - define: { - "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), - }, - entryNames: "[dir]/[hash]", - metafile: true, - plugins: [ - tailwindEsbuildPlugin, - ssrVirtualFilesPlugin({ - entryToPage, - }), - ssrCTXArtifactTracker({ - entryToPage, - post_build_fn: params?.post_build_fn, - }), - ], - jsx: "automatic", - external: [ - "react", - "react-dom", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "bun:*", - ], - // logLevel: "silent", + return { entryToPage, root_file_path }; +} +/** + * Single-flight SSR rebuild. Concurrent callers (each successful hydration + * rebuild triggers this, plus watcher/structural paths) join the in-flight + * rebuild instead of spawning overlapping esbuild builds. + * + * A fresh content change that lands while a rebuild is in flight is picked + * up by the next hydration rebuild cycle (each watcher event re-runs the + * hydration context rebuild, which re-enters this queue). + */ +export default async function enqueueSSRRebuild(params) { + if (rebuild_in_flight) { + return rebuild_in_flight; + } + const run = doSSRRebuild(params).finally(() => { + if (rebuild_in_flight === run) { + rebuild_in_flight = undefined; + } }); - await global.BUNEXT_SSR_BUNDLER_CTX.rebuild(); + rebuild_in_flight = run; + return run; +} +async function doSSRRebuild(params) { + const force = Boolean(params?.force); + const dev = isDevelopment(); + const config = global.BUNEXT_CONFIG; + try { + const { entryToPage, root_file_path } = await computeEntryToPage(); + const entry_keys = [...entryToPage.keys()].sort(); + if (entry_keys.length === 0) { + current_entry_map = entryToPage; + last_entry_keys = entry_keys; + last_root_file_path = root_file_path; + return; + } + // Refresh the virtual contents BEFORE rebuilding so the plugin's + // onLoad sees the latest `.server.ts`/api contents. + current_entry_map = entryToPage; + const entryPoints = [...entryToPage.keys()].map((e) => `ssr-virtual:${e}`); + try { + writeFileSync(path.join(BUNX_TMP_DIR, "ssr-entry-to-page.json"), JSON.stringify(Object(entryToPage), null, 4)); + writeFileSync(path.join(BUNX_TMP_DIR, "ssr-entrypoints.json"), JSON.stringify(entryPoints, null, 4)); + } + catch (error) { } + const structure_changed = !global.BUNEXT_SSR_BUNDLER_CTX || + global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED || + force || + !last_entry_keys || + !arraysEqual(last_entry_keys, entry_keys) || + last_root_file_path !== root_file_path; + if (structure_changed) { + try { + await global.BUNEXT_SSR_BUNDLER_CTX?.dispose(); + } + catch (error) { } + global.BUNEXT_SSR_BUNDLER_CTX = undefined; + global.BUNEXT_SSR_BUNDLER_CTX = await esbuild.context({ + entryPoints, + outdir: BUNX_CWD_MODULE_CACHE_DIR, + bundle: true, + minify: !dev, + format: "esm", + target: "esnext", + platform: "node", + define: { + "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), + }, + entryNames: "[dir]/[hash]", + metafile: true, + plugins: [ + tailwindEsbuildPlugin, + ssrVirtualFilesPlugin({ + getEntryToPage: () => current_entry_map, + }), + ssrCTXArtifactTracker({ + entryToPage, + }), + ], + jsx: "automatic", + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "bun:*", + "bun", + "sqlite-vec", + "better-sqlite3", + ...(config.ssr_compiler_excludes || []), + ], + splitting: true, + logLevel: "silent", + }); + } + await global.BUNEXT_SSR_BUNDLER_CTX.rebuild(); + // Only record the entry set after a successful build so a failed + // build retries with a fresh context next time. + last_entry_keys = entry_keys; + last_root_file_path = root_file_path; + global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false; + global.BUNEXT_BUILD_FAILED = false; + await cleanupArtifacts({ paradigm: "ssr" }); + } + catch (error) { + global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; + global.BUNEXT_BUILD_FAILED = true; + // esbuild errors are reported (deduped) by the ssr artifact tracker's + // onEnd; only re-log unexpected failures. + if (!String(error?.message || "").startsWith("Build failed with")) { + logBuildErrorsOnce([ + { + text: `SSR Bundler Error: ${error?.message || String(error)}`, + }, + ], (line) => log.error(line)); + } + } } diff --git a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.d.ts b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.d.ts index 8f2876d..6e3649e 100644 --- a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.d.ts +++ b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.d.ts @@ -6,6 +6,7 @@ type Params = { }>; post_build_fn?: (params: { artifacts: any[]; + changed_pages?: string[]; }) => Promise | void; build_only?: boolean; }; diff --git a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js index d59606d..1578ef2 100644 --- a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js +++ b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js @@ -1,31 +1,24 @@ import {} from "esbuild"; import { log } from "../../../utils/log"; import grabArtifactsFromBundledResults from "../grab-artifacts-from-bundled-result"; -import buildOnstartErrorHandler from "../build-on-start-error-handler"; import _ from "lodash"; -import pagesSSRBundler from "../pages-ssr-bundler"; +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, writeFileSync } from "fs"; +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"; -const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR, ROOT_DIR } = grabDirNames(); +import cleanupArtifacts from "../../server/cleanup-artifacts"; +const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames(); let build_start = 0; -const MAX_BUILD_STARTS = 2; export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, }) { const artifactTracker = { name: "artifact-tracker", setup(build) { build.onStart(async () => { - global.BUNEXT_MAIN_CTX_BUILD_STARTS++; build_start = performance.now(); - const does_error_file_exist = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE); - if (global.BUNEXT_MAIN_CTX_BUILD_STARTS >= MAX_BUILD_STARTS && - !does_error_file_exist) { - await buildOnstartErrorHandler(); - } }); build.onEnd(async (result) => { if (result.errors.length > 0) { @@ -50,14 +43,28 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, 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(global.BUNEXT_BUNDLER_CTX_MAP[relative_path], { ...artifact, local_path: relative_path }); + _.merge(prior, { + ...artifact, + local_path: relative_path, + }); } } } @@ -70,13 +77,15 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, logBuildErrorsOnce(result.warnings, (line) => log.warn(line)); } global.BUNEXT_BUILD_FAILED = false; - global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0; global.BUNEXT_BUNDLER_CTX_DISPOSED = false; const does_error_file_exist = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE); - // SSR must finish before HMR so server props are fresh + // SSR must finish before HMR so server 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 pagesSSRBundler(); + await enqueueSSRRebuild(); } catch (error) { log.error(`SSR Bundler Error: ${error}`); @@ -91,20 +100,25 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, } else { try { - await pagesSSRBundler(); + await enqueueSSRRebuild(); } catch (error) { log.error(`SSR Bundler Error: ${error}`); } if (artifacts?.[0] && artifacts.length > 0) { try { - await post_build_fn?.({ artifacts }); + 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; }); diff --git a/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.d.ts b/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.d.ts index a0ef893..08a53b8 100644 --- a/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.d.ts +++ b/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.d.ts @@ -4,9 +4,6 @@ type Params = { entryToPage: Map; - post_build_fn?: (params: { - artifacts: any[]; - }) => Promise | void; }; -export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }: Params): Plugin; +export default function ssrCTXArtifactTracker({ entryToPage }: Params): Plugin; export {}; diff --git a/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.js b/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.js index 1bb659d..dc69a32 100644 --- a/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.js +++ b/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.js @@ -7,32 +7,18 @@ import grabRelativePath from "../../../utils/grab-relative-path"; import { logBuildErrorsOnce, resetBuildErrorLog, } from "../../../utils/build-error-log"; import { log } from "../../../utils/log"; let build_start = 0; -let build_starts = 0; -const MAX_BUILD_STARTS = 2; const { BUNX_TMP_DIR, ROOT_DIR } = grabDirNames(); -export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }) { +export default function ssrCTXArtifactTracker({ entryToPage }) { const artifactTracker = { name: "ssr-artifact-tracker", setup(build) { build.onStart(async () => { - build_starts++; build_start = performance.now(); - if (build_starts == MAX_BUILD_STARTS) { - global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; - await global.BUNEXT_SSR_BUNDLER_CTX?.dispose(); - global.BUNEXT_SSR_BUNDLER_CTX = undefined; - } }); build.onEnd(async (result) => { if (result.errors.length > 0) { global.BUNEXT_BUILD_FAILED = true; global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; - try { - await global.BUNEXT_SSR_BUNDLER_CTX?.dispose(); - } - catch { } - global.BUNEXT_SSR_BUNDLER_CTX = undefined; - build_starts = 0; logBuildErrorsOnce(result.errors, (line) => log.error(`SSR Build error: ${line}`)); return; } @@ -53,16 +39,13 @@ export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }) { }; } } - // post_build_fn?.({ artifacts }); - // const elapsed = (performance.now() - build_start).toFixed( - // 0, - // ); - // log.success(`SSR [Built] in ${elapsed}ms`); } try { writeFileSync(path.join(BUNX_TMP_DIR, "ctx-map.json"), JSON.stringify(global.BUNEXT_SSR_BUNDLER_CTX_MAP, null, 4)); } catch (error) { } + const elapsed = (performance.now() - build_start).toFixed(0); + log.success(`SSR [Built] in ${elapsed}ms`); global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false; global.BUNEXT_BUILD_FAILED = false; resetBuildErrorLog(); diff --git a/dist/functions/bundler/plugins/ssr-virtual-files-plugin.d.ts b/dist/functions/bundler/plugins/ssr-virtual-files-plugin.d.ts index d04fe0c..fe9e0c4 100644 --- a/dist/functions/bundler/plugins/ssr-virtual-files-plugin.d.ts +++ b/dist/functions/bundler/plugins/ssr-virtual-files-plugin.d.ts @@ -1,9 +1,16 @@ import type { Plugin } from "esbuild"; import type { PageFiles } from "../../../types"; -type Params = { - entryToPage: Map; +type SSRVirtualEntry = PageFiles & { + tsx: string; }; -export default function ssrVirtualFilesPlugin({ entryToPage }: Params): Plugin; +type Params = { + /** + * Lazily-resolved current virtual entry contents. The map is refreshed + * before every rebuild so edits to `.server.ts`/api files (whose virtual + * entries mirror their raw file content) are picked up by a plain + * `ctx.rebuild()` without recreating the esbuild context. + */ + getEntryToPage: () => Map; +}; +export default function ssrVirtualFilesPlugin({ getEntryToPage }: Params): Plugin; export {}; diff --git a/dist/functions/bundler/plugins/ssr-virtual-files-plugin.js b/dist/functions/bundler/plugins/ssr-virtual-files-plugin.js index fce2b9d..7e73247 100644 --- a/dist/functions/bundler/plugins/ssr-virtual-files-plugin.js +++ b/dist/functions/bundler/plugins/ssr-virtual-files-plugin.js @@ -1,6 +1,5 @@ import path from "path"; -import { log } from "../../../utils/log"; -export default function ssrVirtualFilesPlugin({ entryToPage }) { +export default function ssrVirtualFilesPlugin({ getEntryToPage }) { const virtualPlugin = { name: "ssr-virtual-hydration", setup(build) { @@ -12,7 +11,7 @@ export default function ssrVirtualFilesPlugin({ entryToPage }) { }; }); build.onLoad({ filter: /.*/, namespace: "ssr-virtual" }, (args) => { - const target = entryToPage.get(args.path); + const target = getEntryToPage().get(args.path); if (!target?.tsx) return null; const contents = target.tsx; diff --git a/dist/functions/bunext-init.d.ts b/dist/functions/bunext-init.d.ts index 7f1fa01..add110c 100644 --- a/dist/functions/bunext-init.d.ts +++ b/dist/functions/bunext-init.d.ts @@ -3,7 +3,6 @@ import type { FileSystemRouter, Server } from "bun"; import { type DirNames } from "../utils/grab-dir-names"; import type { BuildContext } from "esbuild"; import grabConstants from "../utils/grab-constants"; -import { type FSWatcher } from "fs"; /** * # Declare Global Variables */ @@ -24,7 +23,7 @@ declare global { [k: string]: BundlerCTXMap; }; var BUNEXT_BUNDLER_REBUILDS: 0; - var BUNEXT_PAGES_SRC_WATCHER: FSWatcher | undefined; + var BUNEXT_PAGES_SRC_WATCHER: any; var BUNEXT_CURRENT_VERSION: string | undefined; var BUNEXT_PAGE_FILES: PageFiles[]; var BUNEXT_ROOT_FILE_UPDATED: boolean; @@ -47,6 +46,13 @@ declare global { var BUNEXT_BUILD_FAILED: boolean; var BUNEXT_CONSTANTS: ReturnType; var BUNEXT_MAIN_CTX_BUILD_STARTS: number; + /** + * Resolves when the one-time dev startup build (hydration + SSR for all + * pages) has completed. The dev server starts listening before this + * resolves; page requests await it so they never render against + * half-built modules. + */ + var BUNEXT_INITIAL_BUILD_PROMISE: Promise | undefined; } type Params = { build_only?: boolean; diff --git a/dist/functions/bunext-init.js b/dist/functions/bunext-init.js index e0f985d..017d14a 100644 --- a/dist/functions/bunext-init.js +++ b/dist/functions/bunext-init.js @@ -62,11 +62,21 @@ export default async function bunextInit(params) { } else if (is_dev) { log.build(`Building Modules ...`); - await allPagesESBuildContextBundler({ - post_build_fn: async () => { - await serverPostBuildFn(); + // Do NOT block on the full-site build: `Bun.serve` (or the start + // server) opens its port immediately and requests await + // BUNEXT_INITIAL_BUILD_PROMISE, so the site becomes reachable while + // the one-time compile still runs in the background. + const initial_build_promise = allPagesESBuildContextBundler({ + post_build_fn: async (params) => { + await serverPostBuildFn(params); }, }); + global.BUNEXT_INITIAL_BUILD_PROMISE = initial_build_promise; + // Errors are logged (deduped) by the build pipeline itself; swallow + // the rejection here so it isn't an unhandled rejection. + initial_build_promise.catch(() => { }); + // Start watching immediately so edits made during the initial build + // are not missed (rebuilds coalesce with the in-flight build). watcherEsbuildCTX(); } else { diff --git a/dist/functions/server/chokidar-watcher-esbuild-ctx.d.ts b/dist/functions/server/chokidar-watcher-esbuild-ctx.d.ts deleted file mode 100644 index 476a038..0000000 --- a/dist/functions/server/chokidar-watcher-esbuild-ctx.d.ts +++ /dev/null @@ -1 +0,0 @@ -export default function chokadirWatcherEsbuildCTX(): Promise; diff --git a/dist/functions/server/chokidar-watcher-esbuild-ctx.js b/dist/functions/server/chokidar-watcher-esbuild-ctx.js deleted file mode 100644 index af825df..0000000 --- a/dist/functions/server/chokidar-watcher-esbuild-ctx.js +++ /dev/null @@ -1,133 +0,0 @@ -import chokidar from "chokidar"; -import path from "path"; -import { existsSync } from "fs"; -import grabDirNames from "../../utils/grab-dir-names"; -import fullRebuild from "./full-rebuild"; -import { AppData } from "../../data/app-data"; -import checkExcludedPatterns from "../../utils/check-excluded-patterns"; -import pagesSSRBundler from "../bundler/pages-ssr-bundler"; -import { log } from "../../utils/log"; -const { ROOT_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); -export default async function chokadirWatcherEsbuildCTX() { - const watcher = chokidar.watch(ROOT_DIR, { - ignored: [ - /(^|[\/\\])\../, - /node_modules/, - /public/, - /\.bunext/, - /\.git/, - /dist/, - /bun\.lockb/, - (path) => path.endsWith(AppData["BunextTmpFileExt"]), - ], - persistent: true, - ignoreInitial: true, - depth: 99, - }); - const handleEvent = async (event, filePath) => { - let owns_recompile = false; - try { - const filename = path.relative(ROOT_DIR, filePath); - if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) { - await fullRebuild(); - return; - } - if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { - await fullRebuild({ msg: `Restarting Bundler ...` }); - } - if (global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED) { - await pagesSSRBundler().catch((error) => { - log.error(`SSR Bundler Error: ${error}`); - }); - } - if (filename.match(/\/styles$/) || filename === "styles") { - owns_recompile = true; - global.BUNEXT_RECOMPILING = true; - await Bun.sleep(1000); - await fullRebuild({ - msg: `Detected new \`styles\` directory. Rebuilding ...`, - }); - return; - } - if (filename.match(/bunext.config\.ts/)) { - await fullRebuild({ - msg: `bunext.config.ts file changed. Rebuilding server ...`, - }); - return; - } - const target_files_match = /\.(tsx?|jsx?|css)$/; - if (event === "change") { - if (filename.match(target_files_match)) { - if (global.BUNEXT_RECOMPILING) - return; - owns_recompile = true; - global.BUNEXT_RECOMPILING = true; - if (filename.match(/.*\.server\.tsx?/)) { - global.BUNEXT_IS_SERVER_COMPONENT = true; - } - if (global.BUNEXT_BUNDLER_CTX) { - await global.BUNEXT_BUNDLER_CTX.rebuild(); - } - if (filename.match(/(404|500)\.tsx?/)) { - 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; - } - if (["add", "unlink", "addDir", "unlinkDir"].includes(event)) { - const is_file_of_interest = !!filename.match(target_files_match) || - event.includes("Dir"); - if (!is_file_of_interest) - return; - if (!filename.match(/^src\/pages\/|\.css$/) || - checkExcludedPatterns({ path: filename }) || - filename.includes(" ")) { - return reloadWatcher(); - } - if (global.BUNEXT_RECOMPILING) - return; - owns_recompile = true; - const action = event.startsWith("add") ? "created" : "deleted"; - const type = filename.match(/\.css$/) - ? "Stylesheet" - : event.includes("Dir") - ? "Directory" - : filename.match(/\/pages\/api\//) - ? "API Route" - : "Page"; - await fullRebuild({ - msg: `${type} ${action}: ${filename}. Rebuilding ...`, - }); - } - } - catch (error) { - log.error(`Watcher rebuild failed: ${error}`); - } - finally { - if (owns_recompile) { - global.BUNEXT_RECOMPILING = false; - global.BUNEXT_IS_SERVER_COMPONENT = false; - } - } - }; - watcher - .on("add", (path) => handleEvent("add", path)) - .on("change", (path) => handleEvent("change", path)) - .on("unlink", (path) => handleEvent("unlink", path)) - .on("addDir", (path) => handleEvent("addDir", path)) - .on("unlinkDir", (path) => handleEvent("unlinkDir", path)); -} -function reloadWatcher() { - if (global.BUNEXT_PAGES_SRC_WATCHER) { - global.BUNEXT_PAGES_SRC_WATCHER.close(); - chokadirWatcherEsbuildCTX(); - } -} diff --git a/dist/functions/server/cleanup-artifacts.d.ts b/dist/functions/server/cleanup-artifacts.d.ts index 6b3bdf0..5d3eee5 100644 --- a/dist/functions/server/cleanup-artifacts.d.ts +++ b/dist/functions/server/cleanup-artifacts.d.ts @@ -1,6 +1,14 @@ -import type { BundlerCTXMap } from "../../types"; type Params = { - new_artifacts: BundlerCTXMap[]; + paradigm: "hydration" | "ssr"; }; -export default function cleanupArtifacts({ new_artifacts }: Params): Promise; +/** + * Deletes orphaned entry outputs left behind by previous rebuilds. + * + * After a rebuild the page/SSR artifact maps only reference the newest + * hashed entry files; every previously hashed entry for the same directory + * is provably unreferenced (generated HTML always uses the current map) and + * is removed. Chunks, non-hashed fallback module files and `map.json` are + * left untouched. + */ +export default function cleanupArtifacts({ paradigm }: Params): Promise; export {}; diff --git a/dist/functions/server/cleanup-artifacts.js b/dist/functions/server/cleanup-artifacts.js index ffe4bd4..0500d8a 100644 --- a/dist/functions/server/cleanup-artifacts.js +++ b/dist/functions/server/cleanup-artifacts.js @@ -2,32 +2,90 @@ import { log } from "../../utils/log"; import path from "path"; import grabDirNames from "../../utils/grab-dir-names"; import { existsSync, readdirSync, statSync, unlinkSync } from "fs"; -const { ROOT_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE_NAME } = grabDirNames(); -export default async function cleanupArtifacts({ new_artifacts }) { +const { ROOT_DIR, HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); +// esbuild's content hashes are 8-char base64-ish (e.g. `CMSI7VSQ`). Entry +// outputs are `/.js` / `/.css`. Shared chunks are +// `chunk-*.js` and MUST be kept: any current entry may still reference an +// older chunk (esbuild reuses unchanged chunks across rebuilds), and +// verifying references would require parsing every entry's imports. +const ENTRY_HASH_FILE = /^[A-Za-z0-9]{8}\.(js|css)$/; +/** + * Deletes orphaned entry outputs left behind by previous rebuilds. + * + * After a rebuild the page/SSR artifact maps only reference the newest + * hashed entry files; every previously hashed entry for the same directory + * is provably unreferenced (generated HTML always uses the current map) and + * is removed. Chunks, non-hashed fallback module files and `map.json` are + * left untouched. + */ +export default async function cleanupArtifacts({ paradigm }) { + const is_hydration = paradigm === "hydration"; + const root_dir = is_hydration + ? HYDRATION_DST_DIR + : BUNX_CWD_MODULE_CACHE_DIR; + const map = is_hydration + ? global.BUNEXT_BUNDLER_CTX_MAP + : global.BUNEXT_SSR_BUNDLER_CTX_MAP; + if (!map || !existsSync(root_dir)) { + return; + } try { - for (let i = 0; i < new_artifacts.length; i++) { - const new_artifact = new_artifacts[i]; - const artifact_public_dir = path.dirname(path.join(ROOT_DIR, new_artifact.path)); - const dir_content = readdirSync(artifact_public_dir); - for (let d = 0; d < dir_content.length; d++) { - const dir_or_file = dir_content[d]; - const full_path = path.join(artifact_public_dir, dir_or_file); - const file_or_path_stats = statSync(full_path); - if (file_or_path_stats.isDirectory() || - dir_or_file == HYDRATION_DST_DIR_MAP_JSON_FILE_NAME) { - continue; - } - if (new_artifact.path.includes(dir_or_file) || - new_artifact.css_path?.includes(dir_or_file)) { - continue; - } - if (existsSync(full_path)) { - unlinkSync(full_path); - } + const keep_by_dir = new Map(); + for (const entry of Object.values(map)) { + if (!entry?.path) + continue; + const full_path = path.join(ROOT_DIR, entry.path); + const dir_path = path.dirname(full_path); + let keep = keep_by_dir.get(dir_path); + if (!keep) { + keep = new Set(); + keep_by_dir.set(dir_path, keep); + } + keep.add(path.basename(full_path)); + if (entry.css_path) { + keep.add(path.basename(entry.css_path)); } } + sweepDir(root_dir, keep_by_dir); } catch (error) { - log.error(error); + log.error(`Cleanup Artifacts Error: ${error?.message || error}`); + } +} +function sweepDir(dir_path, keep_by_dir) { + let entries; + try { + entries = readdirSync(dir_path); + } + catch (error) { + return; + } + for (const name of entries) { + const full_path = path.join(dir_path, name); + let is_dir = false; + try { + is_dir = statSync(full_path).isDirectory(); + } + catch (error) { + continue; + } + if (is_dir) { + sweepDir(full_path, keep_by_dir); + continue; + } + if (!name.match(ENTRY_HASH_FILE)) { + continue; + } + if (name.startsWith("chunk-")) { + continue; + } + const keep = keep_by_dir.get(path.dirname(full_path)); + if (keep?.has(name)) { + continue; + } + try { + unlinkSync(full_path); + } + catch (error) { } } } diff --git a/dist/functions/server/full-rebuild.js b/dist/functions/server/full-rebuild.js index b2f1c7a..f30ff7f 100644 --- a/dist/functions/server/full-rebuild.js +++ b/dist/functions/server/full-rebuild.js @@ -41,8 +41,8 @@ async function doFullRebuild(params) { } catch (error) { } await allPagesESBuildContextBundler({ - post_build_fn: async () => { - await serverPostBuildFn(); + post_build_fn: async (params) => { + await serverPostBuildFn(params); }, }); } diff --git a/dist/functions/server/server-post-build-fn.d.ts b/dist/functions/server/server-post-build-fn.d.ts index d3c94c9..ff16a18 100644 --- a/dist/functions/server/server-post-build-fn.d.ts +++ b/dist/functions/server/server-post-build-fn.d.ts @@ -1,5 +1,14 @@ type Params = { reload_all_controllers?: boolean; + /** + * Relative page paths whose client entry outputs changed in the last + * build. When provided with at least one entry, only controllers + * targeting those pages are re-rendered/re-pushed. When empty or + * omitted, every controller is refreshed (covers server-file-only + * changes, where hydration outputs are untouched but server props must + * still be re-fetched). + */ + changed_pages?: string[]; }; export default function serverPostBuildFn(params?: Params): Promise; export {}; diff --git a/dist/functions/server/server-post-build-fn.js b/dist/functions/server/server-post-build-fn.js index baae774..e610633 100644 --- a/dist/functions/server/server-post-build-fn.js +++ b/dist/functions/server/server-post-build-fn.js @@ -4,6 +4,9 @@ export default async function serverPostBuildFn(params) { if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) { return; } + const changed_set = params?.changed_pages && params.changed_pages.length > 0 + ? new Set(params.changed_pages) + : undefined; const reload_payload = { reload: true }; const reload_enqueue = `event: update\ndata: ${JSON.stringify(reload_payload)}\n\n`; for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) { @@ -14,6 +17,10 @@ export default async function serverPostBuildFn(params) { if (!controller.target_map?.local_path) { continue; } + // Only touch controllers whose page actually changed. + if (changed_set && !changed_set.has(controller.target_map.local_path)) { + continue; + } if (params?.reload_all_controllers) { try { controller.controller.enqueue(reload_enqueue); diff --git a/dist/functions/server/watcher-esbuild-ctx.js b/dist/functions/server/watcher-esbuild-ctx.js index d07b4ac..1b349bd 100644 --- a/dist/functions/server/watcher-esbuild-ctx.js +++ b/dist/functions/server/watcher-esbuild-ctx.js @@ -1,10 +1,12 @@ -import { watch, existsSync, statSync, glob } from "fs"; +import chokidar from "chokidar"; import path from "path"; +import { existsSync } from "fs"; import grabDirNames from "../../utils/grab-dir-names"; import fullRebuild from "./full-rebuild"; import { AppData } from "../../data/app-data"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; -import pagesSSRBundler from "../bundler/pages-ssr-bundler"; +import allPagesESBuildContextBundler from "../bundler/all-pages-esbuild-context-bundler"; +import serverPostBuildFn from "./server-post-build-fn"; import { log } from "../../utils/log"; import { logBuildErrorsOnce } from "../../utils/build-error-log"; const { ROOT_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); @@ -21,7 +23,10 @@ export function restartWatcher() { return; if (Date.now() - last_watcher_restart < 1000) return; - global.BUNEXT_PAGES_SRC_WATCHER.close(); + try { + global.BUNEXT_PAGES_SRC_WATCHER.close(); + } + catch (error) { } global.BUNEXT_PAGES_SRC_WATCHER = undefined; last_watcher_restart = Date.now(); watcherEsbuildCTX(); @@ -33,151 +38,220 @@ export function restartWatcher() { export function watcherRestartAvailable() { return Date.now() - last_watcher_restart > 6000; } -export default async function watcherEsbuildCTX() { - const pages_src_watcher = watch(ROOT_DIR, { - recursive: true, - persistent: true, - }, async (event, filename) => { - let owns_recompile = false; +const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/; +// Rebuild coalescing: editors emit bursts of events per save (change + +// atomic-swap noise), and edits can land while a rebuild is running. +// Events are debounced into a single runner; the runner loops until the +// event queue drains, so a mid-build edit is never silently dropped. +const REBUILD_DEBOUNCE_MS = 200; +let pending_structural = false; +let pending_content_file = undefined; +let debounce_timer = undefined; +function scheduleRebuild() { + if (debounce_timer) { + clearTimeout(debounce_timer); + } + debounce_timer = setTimeout(async () => { + debounce_timer = undefined; try { - if (!filename) - return; - const full_file_path = path.join(ROOT_DIR, filename); - if (global.BUNEXT_CONFIG.exclude_watch_patterns) { - for (let i = 0; i < global.BUNEXT_CONFIG.exclude_watch_patterns.length; i++) { - const watch_pattern = global.BUNEXT_CONFIG.exclude_watch_patterns[i]; - if (watch_pattern instanceof RegExp) { - const is_path_excluded = watch_pattern.test(filename); - if (is_path_excluded) { - return; - } - } - else { - const excluded_path = path.resolve(ROOT_DIR, watch_pattern); - if (excluded_path == full_file_path) { - return; - } - } - } - } - if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) { - await fullRebuild(); - return; - } - if (filename.match(/^\.\w+/)) { - return; - } - if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { - await fullRebuild({ msg: `Restarting Bundler ...` }); - return; - } - if (global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED) { - await pagesSSRBundler().catch((error) => { - log.error(`SSR Bundler Error: ${error}`); - }); - } - if (filename.endsWith(AppData["BunextTmpFileExt"])) { - return; - } - const does_file_exist = existsSync(full_file_path); - const file_stat = does_file_exist - ? statSync(full_file_path) - : undefined; - if (full_file_path.match(/\/styles$/)) { - owns_recompile = true; - global.BUNEXT_RECOMPILING = true; - await Bun.sleep(1000); - await fullRebuild({ - msg: `Detected new \`styles\` directory. Rebuilding ...`, - }); - return; - } - const excluded_match = /node_modules\/|^public\/|^\.bunext\/|^\.git\/|^\.?dist\/|bun\.lockb$/; - if (filename.match(excluded_match)) - return; - if (filename.match(/bunext.config\.ts/)) { - await fullRebuild({ - msg: `bunext.config.ts file changed. Rebuilding server ...`, - }); - return; - } - const target_files_match = /\.(tsx?|jsx?|css)$/; - if (event !== "rename") { - if (filename.match(target_files_match)) { - if (global.BUNEXT_RECOMPILING) - return; - owns_recompile = true; - global.BUNEXT_RECOMPILING = true; - if (filename.match(/.*\.server\.tsx?/)) { - global.BUNEXT_IS_SERVER_COMPONENT = true; - } - if (global.BUNEXT_BUNDLER_CTX && - !global.BUNEXT_BUNDLER_CTX_DISPOSED) { - await global.BUNEXT_BUNDLER_CTX.rebuild(); - } - else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { - await fullRebuild({ - msg: `Restarting Bundler ...`, - }); - return; - } - if (filename.match(/(404|500)\.tsx?/)) { - 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 is_file_of_interest = Boolean(filename.match(target_files_match)) || - file_stat?.isDirectory(); - if (!is_file_of_interest) { - return; - } - if (!filename.match(/^src\/pages\/|\.css$/)) - return reloadWatcher(); - if (checkExcludedPatterns({ path: filename })) - return reloadWatcher(); - if (filename.match(/ /)) - return reloadWatcher(); - if (global.BUNEXT_RECOMPILING) - return; - owns_recompile = true; - const action = does_file_exist ? "created" : "deleted"; - const type = filename.match(/\.css$/) - ? "Sylesheet" - : file_stat?.isDirectory() - ? "Directory" - : filename.match(/\/pages\/api\//) - ? "API Route" - : "Page"; - await fullRebuild({ - msg: `${type} ${action}: ${filename}. Rebuilding ...`, - }); + await runRebuilds(); } catch (error) { - // A "Build failed with N errors" rejection is the same - // failure the tracker's onEnd already reported (deduped). const msg = String(error?.message || error || ""); if (!msg.startsWith("Build failed with")) { logBuildErrorsOnce([{ text: `Watcher rebuild failed: ${msg}` }], (line) => log.error(line)); } } finally { - if (owns_recompile) { - global.BUNEXT_RECOMPILING = false; - global.BUNEXT_IS_SERVER_COMPONENT = false; + global.BUNEXT_RECOMPILING = false; + global.BUNEXT_IS_SERVER_COMPONENT = false; + } + }, REBUILD_DEBOUNCE_MS); +} +/** + * Processes the pending event batch. Structural changes go through the + * all-pages bundler (which re-creates esbuild contexts only when the entry + * set really changed, so atomic-swap saves resolve to incremental rebuilds). + * Content changes rebuild the hydration context incrementally; its artifact + * tracker then queues the single-flighted SSR rebuild and pushes HMR updates. + */ +async function runRebuilds() { + if (global.BUNEXT_RECOMPILING) { + return; + } + global.BUNEXT_RECOMPILING = true; + global.BUNEXT_IS_SERVER_COMPONENT = false; + try { + while (true) { + const structural = pending_structural; + const content_file = pending_content_file; + pending_structural = false; + pending_content_file = undefined; + if (!structural && !content_file) { + break; + } + if (structural) { + global.BUNEXT_ROUTER.reload(); + await allPagesESBuildContextBundler({ + post_build_fn: async (params) => { + await serverPostBuildFn(params); + }, + }); + } + else if (content_file) { + if (global.BUNEXT_BUNDLER_CTX && + !global.BUNEXT_BUNDLER_CTX_DISPOSED) { + await global.BUNEXT_BUNDLER_CTX.rebuild(); + } + else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { + await fullRebuild({ + msg: `Restarting Bundler ...`, + }); + break; + } + if (content_file.match(/(404|500)\.tsx?$/)) { + 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); + } + } + } } } + } + finally { + global.BUNEXT_RECOMPILING = false; + global.BUNEXT_IS_SERVER_COMPONENT = false; + } +} +function isPathExcluded(filename, full_file_path) { + const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns; + if (!user_patterns) + return false; + for (let i = 0; i < user_patterns.length; i++) { + const watch_pattern = user_patterns[i]; + if (watch_pattern instanceof RegExp) { + if (watch_pattern.test(filename)) + return true; + } + else { + const excluded_path = path.resolve(ROOT_DIR, watch_pattern); + if (excluded_path == full_file_path) + return true; + } + } + return false; +} +// Heavy directories are filtered at the watcher level so inotify doesn't +// install watches (and deliver events) for trees the bundler never reads. +function buildIgnoredPatterns() { + const ignored = [ + /(^|[\/\\])\../, + /(^|[\/\\])node_modules([\/\\]|$)/, + /(^|[\/\\])public([\/\\]|$)/, + /(^|[\/\\])\.bunext([\/\\]|$)/, + /(^|[\/\\])\.git([\/\\]|$)/, + /(^|[\/\\])dist([\/\\]|$)/, + /bun\.lockb$/, + (p) => p.endsWith(AppData["BunextTmpFileExt"]), + ]; + const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns; + if (user_patterns) { + for (let i = 0; i < user_patterns.length; i++) { + const pattern = user_patterns[i]; + if (pattern instanceof RegExp) { + ignored.push((p) => pattern.test(path.relative(ROOT_DIR, p))); + } + else { + const excluded_path = path.resolve(ROOT_DIR, pattern); + ignored.push((p) => p === excluded_path); + } + } + } + return ignored; +} +export default async function watcherEsbuildCTX() { + const pages_src_watcher = chokidar.watch(ROOT_DIR, { + ignored: buildIgnoredPatterns(), + persistent: true, + ignoreInitial: true, }); + watcherHandlers(pages_src_watcher, (event, filePath) => handleEvent(event, filePath)); global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher; } -function reloadWatcher() { - restartWatcher(); +function handleEvent(event, filePath) { + try { + const filename = path.relative(ROOT_DIR, filePath); + const full_file_path = filePath; + if (isPathExcluded(filename, full_file_path)) { + return; + } + if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) { + fullRebuild(); + return; + } + if (filename.endsWith(AppData["BunextTmpFileExt"])) { + return; + } + if (full_file_path.match(/\/styles$/)) { + pending_structural = false; + pending_content_file = undefined; + if (debounce_timer) + clearTimeout(debounce_timer); + setTimeout(() => { + fullRebuild({ + msg: `Detected new \`styles\` directory. Rebuilding ...`, + }); + }, 1000); + return; + } + if (filename.match(/bunext\.config\.ts/)) { + pending_structural = false; + pending_content_file = undefined; + if (debounce_timer) + clearTimeout(debounce_timer); + fullRebuild({ + msg: `bunext.config.ts file changed. Rebuilding server ...`, + }); + return; + } + if (event === "change") { + if (filename.match(TARGET_FILES_MATCH)) { + pending_content_file = full_file_path; + scheduleRebuild(); + } + return; + } + // add / unlink / addDir / unlinkDir + const is_dir_event = event === "addDir" || event === "unlinkDir"; + const is_file_of_interest = Boolean(filename.match(TARGET_FILES_MATCH)) || is_dir_event; + if (!is_file_of_interest) { + return; + } + // Only page routes and stylesheets are structural; anything else + // (e.g. a component being added) is covered by the next content + // rebuild and needs no context recreation. + if (!filename.match(/^src\/pages\/|\.css$/) || + checkExcludedPatterns({ path: filename }) || + filename.match(/ /)) { + return; + } + pending_structural = true; + scheduleRebuild(); + } + catch (error) { + logBuildErrorsOnce([{ text: `Watcher error: ${error?.message || error}` }], (line) => log.error(line)); + } +} +function watcherHandlers(watcher, handleEvent) { + watcher + .on("add", (p) => handleEvent("add", p)) + .on("change", (p) => handleEvent("change", p)) + .on("unlink", (p) => handleEvent("unlink", p)) + .on("addDir", (p) => handleEvent("addDir", p)) + .on("unlinkDir", (p) => handleEvent("unlinkDir", p)); } diff --git a/dist/functions/server/web-pages/handle-web-pages.js b/dist/functions/server/web-pages/handle-web-pages.js index 2b95c3b..13978ae 100644 --- a/dist/functions/server/web-pages/handle-web-pages.js +++ b/dist/functions/server/web-pages/handle-web-pages.js @@ -22,6 +22,15 @@ export default async function handleWebPages({ req, }) { return new Response(existing_cache, res_opts); } } + // The dev server port opens before the one-time startup build + // finishes. First page render waits for it so we never serve HTML + // against half-built hydration/SSR modules. + if (is_dev && global.BUNEXT_INITIAL_BUILD_PROMISE) { + try { + await global.BUNEXT_INITIAL_BUILD_PROMISE; + } + catch (error) { } + } const componentRes = await grabPageComponent({ req, }); diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts index 3d7e422..6ff9523 100644 --- a/src/commands/build/index.ts +++ b/src/commands/build/index.ts @@ -3,7 +3,6 @@ import { log } from "../../utils/log"; import grabDirNames from "../../utils/grab-dir-names"; import { rmSync, writeFileSync } from "fs"; import bunextInit from "../../functions/bunext-init"; -import pagesSSRBundler from "../../functions/bundler/pages-ssr-bundler"; import type { BuildDataType } from "../../types"; import { v4 } from "uuid"; @@ -32,7 +31,6 @@ export default function () { global.BUNEXT_SKIPPED_BROWSER_MODULES = new Set(); await bunextInit({ build_only: true }); - // await pagesSSRBundler(); // A failed bundle must fail the command: CI and scripts rely on // the exit code instead of grepping logs. diff --git a/src/functions/bundler/all-pages-esbuild-context-bundler.ts b/src/functions/bundler/all-pages-esbuild-context-bundler.ts index f126815..80a6aef 100644 --- a/src/functions/bundler/all-pages-esbuild-context-bundler.ts +++ b/src/functions/bundler/all-pages-esbuild-context-bundler.ts @@ -12,19 +12,36 @@ import { logBuildErrorsOnce, } from "../../utils/build-error-log"; import { log } from "../../utils/log"; +import grabRootFilePath from "../server/web-pages/grab-root-file-path"; const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames(); type Params = { post_build_fn?: (params: { artifacts: BundlerCTXMap[]; + changed_pages?: string[]; }) => Promise | void; build_only?: boolean; start?: boolean; }; +type HydrationVirtualEntry = PageFiles & { tsx: string }; + +// Snapshot of the entry key set + root file presence of the last build. +// When it differs from the freshly computed set, the esbuild context must +// be recreated (entry points are fixed at context creation). +let last_entry_keys: string[] | undefined = undefined; +let last_root_file_path: string | undefined = undefined; + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + export default async function allPagesESBuildContextBundler(params?: Params) { - const _t0 = Date.now(); try { const pages = grabAllPages({ exclude_api: true }); @@ -32,7 +49,7 @@ export default async function allPagesESBuildContextBundler(params?: Params) { const dev = isDevelopment(); - const entryToPage = new Map(); + const entryToPage = new Map(); for (const page of pages) { const tsx = await grabClientHydrationScript({ @@ -48,7 +65,6 @@ export default async function allPagesESBuildContextBundler(params?: Params) { `${page.url_path}.tsx`, ); - // await Bun.write(entryFile, txt, { createPath: true }); entryToPage.set(entryFile, { ...page, tsx }); } @@ -56,54 +72,70 @@ export default async function allPagesESBuildContextBundler(params?: Params) { (e) => `hydration-virtual:${e}`, ); - // This is a brand-new context: wipe the previous context's failure - // history so the "2 failed starts => dispose" guard starts fresh. - global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0; + const entry_keys = [...entryToPage.keys()].sort(); + const { root_file_path } = grabRootFilePath(); - global.BUNEXT_BUNDLER_CTX = await esbuild.context({ - entryPoints, - outdir: HYDRATION_DST_DIR, - bundle: true, - minify: !dev, - format: "esm", - target: "es2020", - platform: "browser", - define: { - "process.env.NODE_ENV": JSON.stringify( - dev ? "development" : "production", - ), - }, - entryNames: "[dir]/[hash]", - metafile: true, - plugins: [ - forceExternalReact(), - tailwindEsbuildPlugin, - virtualFilesPlugin({ - entryToPage, - }), - esbuildCTXArtifactTracker({ - entryToPage, - post_build_fn: params?.post_build_fn, - build_only: params?.build_only || params?.start, - }), - ], - jsx: "automatic", - splitting: true, - treeShaking: true, - external: [ - "react", - "react-dom", - "react-dom/client", - "react/jsx-runtime", - "react/jsx-dev-runtime", - ...(global.BUNEXT_CONFIG.page_compiler_excludes || []), - ], - // esbuild's own error logging prints per attempt; the - // artifact-tracker plugin reports errors (deduped, once) itself. - logLevel: "silent", - }); + const structure_changed = + !global.BUNEXT_BUNDLER_CTX || + global.BUNEXT_BUNDLER_CTX_DISPOSED || + !last_entry_keys || + !arraysEqual(last_entry_keys, entry_keys) || + last_root_file_path !== root_file_path; - await global.BUNEXT_BUNDLER_CTX.rebuild(); + if (structure_changed) { + try { + await global.BUNEXT_BUNDLER_CTX?.dispose(); + } catch (error) {} + global.BUNEXT_BUNDLER_CTX = undefined; + + global.BUNEXT_BUNDLER_CTX = await esbuild.context({ + entryPoints, + outdir: HYDRATION_DST_DIR, + bundle: true, + minify: !dev, + format: "esm", + target: "es2020", + platform: "browser", + define: { + "process.env.NODE_ENV": JSON.stringify( + dev ? "development" : "production", + ), + }, + entryNames: "[dir]/[hash]", + metafile: true, + plugins: [ + forceExternalReact(), + tailwindEsbuildPlugin, + virtualFilesPlugin({ + entryToPage, + }), + esbuildCTXArtifactTracker({ + entryToPage, + post_build_fn: params?.post_build_fn, + build_only: params?.build_only || params?.start, + }), + ], + jsx: "automatic", + splitting: true, + treeShaking: true, + external: [ + "react", + "react-dom", + "react-dom/client", + "react/jsx-runtime", + "react/jsx-dev-runtime", + ...(global.BUNEXT_CONFIG.page_compiler_excludes || []), + ], + // esbuild's own error logging prints per attempt; the + // artifact-tracker plugin reports errors (deduped, once) itself. + logLevel: "silent", + }); + + last_entry_keys = entry_keys; + last_root_file_path = root_file_path; + } + + await global.BUNEXT_BUNDLER_CTX!.rebuild(); } catch (error: any) { global.BUNEXT_BUILD_FAILED = true; diff --git a/src/functions/bundler/pages-ssr-bundler.ts b/src/functions/bundler/pages-ssr-bundler.ts deleted file mode 100644 index e1e60f6..0000000 --- a/src/functions/bundler/pages-ssr-bundler.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as esbuild from "esbuild"; -import grabAllPages from "../../utils/grab-all-pages"; -import grabDirNames from "../../utils/grab-dir-names"; -import isDevelopment from "../../utils/is-development"; -import tailwindEsbuildPlugin from "../server/web-pages/tailwind-esbuild-plugin"; -import type { PageFiles } from "../../types"; -import grabPageReactComponentString from "../server/web-pages/grab-page-react-component-string"; -import grabRootFilePath from "../server/web-pages/grab-root-file-path"; -import ssrVirtualFilesPlugin from "./plugins/ssr-virtual-files-plugin"; -import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker"; -import { writeFileSync } from "fs"; -import path from "path"; -import { log } from "../../utils/log"; -import { - logBuildErrorsOnce, -} from "../../utils/build-error-log"; - -const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); - -type Params = { - post_build_fn?: (params: { artifacts: any[] }) => Promise | void; -}; - -export default async function pagesSSRBundler(params?: Params) { - const pages = grabAllPages({ - include_server: true, - }); - const dev = isDevelopment(); - const config = global.BUNEXT_CONFIG; - - try { - writeFileSync( - path.join(BUNX_TMP_DIR, "ssr-pages.json"), - JSON.stringify(pages, null, 4), - ); - } catch (error) {} - - const entryToPage = new Map(); - const { root_file_path } = grabRootFilePath(); - - for (const page of pages) { - if ( - page.local_path.match(/\/pages\/api\//) || - page.local_path.match(/\.server\.tsx?$/) - ) { - const ts = await Bun.file(page.local_path).text(); - if ( - ts.match( - /(export default)|(export \w+ handler)|(export \w+ server)/, - ) - ) { - entryToPage.set(page.local_path, { ...page, tsx: ts }); - } - continue; - } - - const tsx = grabPageReactComponentString({ - file_path: page.local_path, - root_file_path, - }); - - if (!tsx) continue; - if (!tsx.match(/export default/)) continue; - - entryToPage.set(page.local_path, { ...page, tsx }); - } - - const entryPoints = [...entryToPage.keys()].map((e) => `ssr-virtual:${e}`); - - try { - writeFileSync( - path.join(BUNX_TMP_DIR, "ssr-entry-to-page.json"), - JSON.stringify(Object(entryToPage), null, 4), - ); - writeFileSync( - path.join(BUNX_TMP_DIR, "ssr-entrypoints.json"), - JSON.stringify(entryPoints, null, 4), - ); - } catch (error) {} - - try { - await esbuild.build({ - entryPoints, - outdir: BUNX_CWD_MODULE_CACHE_DIR, - bundle: true, - minify: !dev, - format: "esm", - target: "esnext", - platform: "node", - define: { - "process.env.NODE_ENV": JSON.stringify( - dev ? "development" : "production", - ), - }, - entryNames: "[dir]/[hash]", - metafile: true, - plugins: [ - tailwindEsbuildPlugin, - ssrVirtualFilesPlugin({ - entryToPage, - }), - ssrCTXArtifactTracker({ - entryToPage, - post_build_fn: params?.post_build_fn, - }), - ], - jsx: "automatic", - external: [ - "react", - "react-dom", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "bun:*", - "bun", - "sqlite-vec", - "better-sqlite3", - ...(config.ssr_compiler_excludes || []), - ], - splitting: true, - logLevel: "silent", - }); - } catch (error: any) { - global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; - global.BUNEXT_BUILD_FAILED = true; - logBuildErrorsOnce( - [ - { - text: `SSR Bundler Error: ${ - error?.message || String(error) - }`, - }, - ], - (line) => log.error(line), - ); - } -} diff --git a/src/functions/bundler/pages-ssr-context-bundler.ts b/src/functions/bundler/pages-ssr-context-bundler.ts index 5443122..80f95c9 100644 --- a/src/functions/bundler/pages-ssr-context-bundler.ts +++ b/src/functions/bundler/pages-ssr-context-bundler.ts @@ -8,29 +8,77 @@ import grabPageReactComponentString from "../server/web-pages/grab-page-react-co import grabRootFilePath from "../server/web-pages/grab-root-file-path"; import ssrVirtualFilesPlugin from "./plugins/ssr-virtual-files-plugin"; import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker"; +import cleanupArtifacts from "../server/cleanup-artifacts"; +import { writeFileSync } from "fs"; +import path from "path"; +import { log } from "../../utils/log"; +import { + logBuildErrorsOnce, +} from "../../utils/build-error-log"; -const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); +const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); -type Params = { - post_build_fn?: (params: { artifacts: any[] }) => Promise | void; +type SSRVirtualEntry = PageFiles & { tsx: string }; + +// Current virtual entry contents. The SSR virtual plugin resolves its +// onLoad contents lazily from this map on every rebuild, so edits to +// `.server.ts`/api files (whose entries mirror their raw file content) are +// picked up without recreating the esbuild context. +let current_entry_map = new Map(); +// Entry key set + root file presence of the last *successful* build. When +// this differs from the freshly computed set, the context must be +// recreated (esbuild cannot add/remove entry points on a live context). +let last_entry_keys: string[] | undefined = undefined; +let last_root_file_path: string | undefined = undefined; + +let rebuild_in_flight: Promise | undefined; + +type RebuildParams = { + /** + * Force a context recreation even when the entry set is unchanged. + * Used by `fullRebuild` after it disposed the contexts. + */ + force?: boolean; }; -export default async function pagesSSRContextBundler(params?: Params) { - const pages = grabAllPages(); - const dev = isDevelopment(); - - if (global.BUNEXT_SSR_BUNDLER_CTX) { - await global.BUNEXT_SSR_BUNDLER_CTX.dispose(); - global.BUNEXT_SSR_BUNDLER_CTX = undefined; +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; } + return true; +} - const entryToPage = new Map(); +async function computeEntryToPage(): Promise<{ + entryToPage: Map; + root_file_path: string | undefined; +}> { + const pages = grabAllPages({ + include_server: true, + }); const { root_file_path } = grabRootFilePath(); + const entryToPage = new Map(); + + try { + writeFileSync( + path.join(BUNX_TMP_DIR, "ssr-pages.json"), + JSON.stringify(pages, null, 4), + ); + } catch (error) {} for (const page of pages) { - if (page.local_path.match(/\/pages\/api\//)) { + if ( + page.local_path.match(/\/pages\/api\//) || + page.local_path.match(/\.server\.tsx?$/) + ) { const ts = await Bun.file(page.local_path).text(); - entryToPage.set(page.local_path, { ...page, tsx: ts }); + if ( + ts.match( + /(export default)|(export \w+ handler)|(export \w+ server)/, + ) + ) { + entryToPage.set(page.local_path, { ...page, tsx: ts }); + } continue; } @@ -40,47 +88,155 @@ export default async function pagesSSRContextBundler(params?: Params) { }); if (!tsx) continue; + if (!tsx.match(/export default/)) continue; entryToPage.set(page.local_path, { ...page, tsx }); } - const entryPoints = [...entryToPage.keys()].map((e) => `ssr-virtual:${e}`); + return { entryToPage, root_file_path }; +} - global.BUNEXT_SSR_BUNDLER_CTX = await esbuild.context({ - entryPoints, - outdir: BUNX_CWD_MODULE_CACHE_DIR, - bundle: true, - minify: !dev, - format: "esm", - target: "esnext", - platform: "node", - define: { - "process.env.NODE_ENV": JSON.stringify( - dev ? "development" : "production", - ), - }, - entryNames: "[dir]/[hash]", - metafile: true, - plugins: [ - tailwindEsbuildPlugin, - ssrVirtualFilesPlugin({ - entryToPage, - }), - ssrCTXArtifactTracker({ - entryToPage, - post_build_fn: params?.post_build_fn, - }), - ], - jsx: "automatic", - external: [ - "react", - "react-dom", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "bun:*", - ], - // logLevel: "silent", +/** + * Single-flight SSR rebuild. Concurrent callers (each successful hydration + * rebuild triggers this, plus watcher/structural paths) join the in-flight + * rebuild instead of spawning overlapping esbuild builds. + * + * A fresh content change that lands while a rebuild is in flight is picked + * up by the next hydration rebuild cycle (each watcher event re-runs the + * hydration context rebuild, which re-enters this queue). + */ +export default async function enqueueSSRRebuild(params?: RebuildParams) { + if (rebuild_in_flight) { + return rebuild_in_flight; + } + + const run = doSSRRebuild(params).finally(() => { + if (rebuild_in_flight === run) { + rebuild_in_flight = undefined; + } }); - await global.BUNEXT_SSR_BUNDLER_CTX.rebuild(); + rebuild_in_flight = run; + return run; } + +async function doSSRRebuild(params?: RebuildParams) { + const force = Boolean(params?.force); + const dev = isDevelopment(); + const config = global.BUNEXT_CONFIG; + + try { + const { entryToPage, root_file_path } = await computeEntryToPage(); + const entry_keys = [...entryToPage.keys()].sort(); + + if (entry_keys.length === 0) { + current_entry_map = entryToPage; + last_entry_keys = entry_keys; + last_root_file_path = root_file_path; + return; + } + + // Refresh the virtual contents BEFORE rebuilding so the plugin's + // onLoad sees the latest `.server.ts`/api contents. + current_entry_map = entryToPage; + + const entryPoints = [...entryToPage.keys()].map( + (e) => `ssr-virtual:${e}`, + ); + + try { + writeFileSync( + path.join(BUNX_TMP_DIR, "ssr-entry-to-page.json"), + JSON.stringify(Object(entryToPage), null, 4), + ); + writeFileSync( + path.join(BUNX_TMP_DIR, "ssr-entrypoints.json"), + JSON.stringify(entryPoints, null, 4), + ); + } catch (error) {} + + const structure_changed = + !global.BUNEXT_SSR_BUNDLER_CTX || + global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED || + force || + !last_entry_keys || + !arraysEqual(last_entry_keys, entry_keys) || + last_root_file_path !== root_file_path; + + if (structure_changed) { + try { + await global.BUNEXT_SSR_BUNDLER_CTX?.dispose(); + } catch (error) {} + global.BUNEXT_SSR_BUNDLER_CTX = undefined; + + global.BUNEXT_SSR_BUNDLER_CTX = await esbuild.context({ + entryPoints, + outdir: BUNX_CWD_MODULE_CACHE_DIR, + bundle: true, + minify: !dev, + format: "esm", + target: "esnext", + platform: "node", + define: { + "process.env.NODE_ENV": JSON.stringify( + dev ? "development" : "production", + ), + }, + entryNames: "[dir]/[hash]", + metafile: true, + plugins: [ + tailwindEsbuildPlugin, + ssrVirtualFilesPlugin({ + getEntryToPage: () => current_entry_map, + }), + ssrCTXArtifactTracker({ + entryToPage, + }), + ], + jsx: "automatic", + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "bun:*", + "bun", + "sqlite-vec", + "better-sqlite3", + ...(config.ssr_compiler_excludes || []), + ], + splitting: true, + logLevel: "silent", + }); + } + + await global.BUNEXT_SSR_BUNDLER_CTX!.rebuild(); + + // Only record the entry set after a successful build so a failed + // build retries with a fresh context next time. + last_entry_keys = entry_keys; + last_root_file_path = root_file_path; + global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false; + global.BUNEXT_BUILD_FAILED = false; + + await cleanupArtifacts({ paradigm: "ssr" }); + } catch (error: any) { + global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; + global.BUNEXT_BUILD_FAILED = true; + + // esbuild errors are reported (deduped) by the ssr artifact tracker's + // onEnd; only re-log unexpected failures. + if (!String(error?.message || "").startsWith("Build failed with")) { + logBuildErrorsOnce( + [ + { + text: `SSR Bundler Error: ${ + error?.message || String(error) + }`, + }, + ], + (line) => log.error(line), + ); + } + } +} \ No newline at end of file diff --git a/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts b/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts index 54333a4..9613222 100644 --- a/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts +++ b/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts @@ -2,25 +2,23 @@ import { type Plugin } from "esbuild"; import type { PageFiles } from "../../../types"; import { log } from "../../../utils/log"; import grabArtifactsFromBundledResults from "../grab-artifacts-from-bundled-result"; -import buildOnstartErrorHandler from "../build-on-start-error-handler"; import _ from "lodash"; -import pagesSSRBundler from "../pages-ssr-bundler"; +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, writeFileSync } from "fs"; +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"; -const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR, ROOT_DIR } = - grabDirNames(); +const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames(); let build_start = 0; -const MAX_BUILD_STARTS = 2; type Params = { entryToPage: Map< @@ -29,7 +27,10 @@ type Params = { tsx: string; } >; - post_build_fn?: (params: { artifacts: any[] }) => Promise | void; + post_build_fn?: (params: { + artifacts: any[]; + changed_pages?: string[]; + }) => Promise | void; build_only?: boolean; }; @@ -42,19 +43,7 @@ export default function esbuildCTXArtifactTracker({ name: "artifact-tracker", setup(build) { build.onStart(async () => { - global.BUNEXT_MAIN_CTX_BUILD_STARTS++; build_start = performance.now(); - - const does_error_file_exist = existsSync( - BUNX_BUNDLER_ERROR_EXIT_FILE, - ); - - if ( - global.BUNEXT_MAIN_CTX_BUILD_STARTS >= MAX_BUILD_STARTS && - !does_error_file_exist - ) { - await buildOnstartErrorHandler(); - } }); build.onEnd(async (result) => { @@ -92,6 +81,12 @@ export default function esbuildCTXArtifactTracker({ 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]; @@ -104,13 +99,22 @@ export default function esbuildCTXArtifactTracker({ 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( - global.BUNEXT_BUNDLER_CTX_MAP[ - relative_path - ], - { ...artifact, local_path: relative_path }, - ); + _.merge(prior, { + ...artifact, + local_path: relative_path, + }); } } } @@ -129,17 +133,19 @@ export default function esbuildCTXArtifactTracker({ } global.BUNEXT_BUILD_FAILED = false; - global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0; global.BUNEXT_BUNDLER_CTX_DISPOSED = false; const does_error_file_exist = existsSync( BUNX_BUNDLER_ERROR_EXIT_FILE, ); - // SSR must finish before HMR so server props are fresh + // SSR must finish before HMR so server 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 pagesSSRBundler(); + await enqueueSSRRebuild(); } catch (error) { log.error(`SSR Bundler Error: ${error}`); } @@ -154,20 +160,26 @@ export default function esbuildCTXArtifactTracker({ await fullRebuild(); } else { try { - await pagesSSRBundler(); + await enqueueSSRRebuild(); } catch (error) { log.error(`SSR Bundler Error: ${error}`); } if (artifacts?.[0] && artifacts.length > 0) { try { - await post_build_fn?.({ artifacts }); + 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; }); diff --git a/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts b/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts index fa72b39..3408c0f 100644 --- a/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts +++ b/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts @@ -12,10 +12,8 @@ import { import { log } from "../../../utils/log"; let build_start = 0; -let build_starts = 0; -const MAX_BUILD_STARTS = 2; -const { BUNX_TMP_DIR, ROOT_DIR } = grabDirNames(); +const { BUNX_TMP_DIR } = grabDirNames(); type Params = { entryToPage: Map< @@ -24,35 +22,20 @@ type Params = { tsx: string; } >; - post_build_fn?: (params: { artifacts: any[] }) => Promise | void; }; -export default function ssrCTXArtifactTracker({ - entryToPage, - post_build_fn, -}: Params) { +export default function ssrCTXArtifactTracker({ entryToPage }: Params) { const artifactTracker: Plugin = { name: "ssr-artifact-tracker", setup(build) { build.onStart(async () => { - build_starts++; build_start = performance.now(); - if (build_starts == MAX_BUILD_STARTS) { - global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; - await global.BUNEXT_SSR_BUNDLER_CTX?.dispose(); - global.BUNEXT_SSR_BUNDLER_CTX = undefined; - } }); build.onEnd(async (result) => { if (result.errors.length > 0) { global.BUNEXT_BUILD_FAILED = true; global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; - try { - await global.BUNEXT_SSR_BUNDLER_CTX?.dispose(); - } catch {} - global.BUNEXT_SSR_BUNDLER_CTX = undefined; - build_starts = 0; logBuildErrorsOnce(result.errors, (line) => log.error(`SSR Build error: ${line}`), ); @@ -83,13 +66,6 @@ export default function ssrCTXArtifactTracker({ }; } } - - // post_build_fn?.({ artifacts }); - - // const elapsed = (performance.now() - build_start).toFixed( - // 0, - // ); - // log.success(`SSR [Built] in ${elapsed}ms`); } try { @@ -103,6 +79,9 @@ export default function ssrCTXArtifactTracker({ ); } catch (error) {} + // const elapsed = (performance.now() - build_start).toFixed(0); + // log.success(`SSR [Built] in ${elapsed}ms`); + global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false; global.BUNEXT_BUILD_FAILED = false; resetBuildErrorLog(); diff --git a/src/functions/bundler/plugins/ssr-virtual-files-plugin.ts b/src/functions/bundler/plugins/ssr-virtual-files-plugin.ts index 3be216f..a0fae1d 100644 --- a/src/functions/bundler/plugins/ssr-virtual-files-plugin.ts +++ b/src/functions/bundler/plugins/ssr-virtual-files-plugin.ts @@ -1,18 +1,20 @@ import type { Plugin } from "esbuild"; import path from "path"; import type { PageFiles } from "../../../types"; -import { log } from "../../../utils/log"; + +type SSRVirtualEntry = PageFiles & { tsx: string }; type Params = { - entryToPage: Map< - string, - PageFiles & { - tsx: string; - } - >; + /** + * Lazily-resolved current virtual entry contents. The map is refreshed + * before every rebuild so edits to `.server.ts`/api files (whose virtual + * entries mirror their raw file content) are picked up by a plain + * `ctx.rebuild()` without recreating the esbuild context. + */ + getEntryToPage: () => Map; }; -export default function ssrVirtualFilesPlugin({ entryToPage }: Params) { +export default function ssrVirtualFilesPlugin({ getEntryToPage }: Params) { const virtualPlugin: Plugin = { name: "ssr-virtual-hydration", setup(build) { @@ -25,7 +27,7 @@ export default function ssrVirtualFilesPlugin({ entryToPage }: Params) { }); build.onLoad({ filter: /.*/, namespace: "ssr-virtual" }, (args) => { - const target = entryToPage.get(args.path); + const target = getEntryToPage().get(args.path); if (!target?.tsx) return null; const contents = target.tsx; diff --git a/src/functions/bunext-init.ts b/src/functions/bunext-init.ts index ff9f1d5..538be5e 100644 --- a/src/functions/bunext-init.ts +++ b/src/functions/bunext-init.ts @@ -16,7 +16,7 @@ import serverPostBuildFn from "./server/server-post-build-fn"; import reactModulesBundler from "./bundler/react-modules-bundler"; import grabConstants from "../utils/grab-constants"; import watcherEsbuildCTX from "./server/watcher-esbuild-ctx"; -import { readFileSync, type FSWatcher } from "fs"; +import { readFileSync } from "fs"; /** * # Declare Global Variables @@ -35,7 +35,7 @@ declare global { var BUNEXT_SSR_BUNDLER_CTX_MAP: { [k: string]: BundlerCTXMap }; // var BUNEXT_API_ROUTES_BUNDLER_CTX_MAP: { [k: string]: BundlerCTXMap }; var BUNEXT_BUNDLER_REBUILDS: 0; - var BUNEXT_PAGES_SRC_WATCHER: FSWatcher | undefined; + var BUNEXT_PAGES_SRC_WATCHER: any; var BUNEXT_CURRENT_VERSION: string | undefined; var BUNEXT_PAGE_FILES: PageFiles[]; var BUNEXT_ROOT_FILE_UPDATED: boolean; @@ -54,6 +54,13 @@ declare global { var BUNEXT_BUILD_FAILED: boolean; var BUNEXT_CONSTANTS: ReturnType; var BUNEXT_MAIN_CTX_BUILD_STARTS: number; + /** + * Resolves when the one-time dev startup build (hydration + SSR for all + * pages) has completed. The dev server starts listening before this + * resolves; page requests await it so they never render against + * half-built modules. + */ + var BUNEXT_INITIAL_BUILD_PROMISE: Promise | undefined; } const dirNames = grabDirNames(); @@ -129,11 +136,25 @@ export default async function bunextInit(params?: Params) { await allPagesESBuildContextBundler({ build_only: params.build_only }); } else if (is_dev) { log.build(`Building Modules ...`); - await allPagesESBuildContextBundler({ - post_build_fn: async () => { - await serverPostBuildFn(); + + // Do NOT block on the full-site build: `Bun.serve` (or the start + // server) opens its port immediately and requests await + // BUNEXT_INITIAL_BUILD_PROMISE, so the site becomes reachable while + // the one-time compile still runs in the background. + const initial_build_promise = allPagesESBuildContextBundler({ + post_build_fn: async (params) => { + await serverPostBuildFn(params); }, }); + + global.BUNEXT_INITIAL_BUILD_PROMISE = initial_build_promise; + + // Errors are logged (deduped) by the build pipeline itself; swallow + // the rejection here so it isn't an unhandled rejection. + initial_build_promise.catch(() => {}); + + // Start watching immediately so edits made during the initial build + // are not missed (rebuilds coalesce with the in-flight build). watcherEsbuildCTX(); } else { if (are_modules_aready_built) { diff --git a/src/functions/server/chokidar-watcher-esbuild-ctx.ts b/src/functions/server/chokidar-watcher-esbuild-ctx.ts deleted file mode 100644 index 3962ed0..0000000 --- a/src/functions/server/chokidar-watcher-esbuild-ctx.ts +++ /dev/null @@ -1,161 +0,0 @@ -import chokidar from "chokidar"; -import path from "path"; -import { existsSync } from "fs"; -import grabDirNames from "../../utils/grab-dir-names"; -import fullRebuild from "./full-rebuild"; -import { AppData } from "../../data/app-data"; -import checkExcludedPatterns from "../../utils/check-excluded-patterns"; -import pagesSSRBundler from "../bundler/pages-ssr-bundler"; -import { log } from "../../utils/log"; - -const { ROOT_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); - -export default async function chokadirWatcherEsbuildCTX() { - const watcher = chokidar.watch(ROOT_DIR, { - ignored: [ - /(^|[\/\\])\../, - /node_modules/, - /public/, - /\.bunext/, - /\.git/, - /dist/, - /bun\.lockb/, - (path: string) => path.endsWith(AppData["BunextTmpFileExt"]), - ], - persistent: true, - ignoreInitial: true, - depth: 99, - }); - - const handleEvent = async ( - event: "add" | "change" | "unlink" | "addDir" | "unlinkDir", - filePath: string, - ) => { - let owns_recompile = false; - - try { - const filename = path.relative(ROOT_DIR, filePath); - - if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) { - await fullRebuild(); - return; - } - - if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { - await fullRebuild({ msg: `Restarting Bundler ...` }); - } - - if (global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED) { - await pagesSSRBundler().catch((error) => { - log.error(`SSR Bundler Error: ${error}`); - }); - } - - if (filename.match(/\/styles$/) || filename === "styles") { - owns_recompile = true; - global.BUNEXT_RECOMPILING = true; - await Bun.sleep(1000); - await fullRebuild({ - msg: `Detected new \`styles\` directory. Rebuilding ...`, - }); - return; - } - - if (filename.match(/bunext.config\.ts/)) { - await fullRebuild({ - msg: `bunext.config.ts file changed. Rebuilding server ...`, - }); - return; - } - - const target_files_match = /\.(tsx?|jsx?|css)$/; - - if (event === "change") { - if (filename.match(target_files_match)) { - if (global.BUNEXT_RECOMPILING) return; - owns_recompile = true; - global.BUNEXT_RECOMPILING = true; - - if (filename.match(/.*\.server\.tsx?/)) { - global.BUNEXT_IS_SERVER_COMPONENT = true; - } - - if (global.BUNEXT_BUNDLER_CTX) { - await global.BUNEXT_BUNDLER_CTX.rebuild(); - } - - if (filename.match(/(404|500)\.tsx?/)) { - 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; - } - - if (["add", "unlink", "addDir", "unlinkDir"].includes(event)) { - const is_file_of_interest = - !!filename.match(target_files_match) || - event.includes("Dir"); - - if (!is_file_of_interest) return; - - if ( - !filename.match(/^src\/pages\/|\.css$/) || - checkExcludedPatterns({ path: filename }) || - filename.includes(" ") - ) { - return reloadWatcher(); - } - - if (global.BUNEXT_RECOMPILING) return; - - owns_recompile = true; - const action = event.startsWith("add") ? "created" : "deleted"; - const type = filename.match(/\.css$/) - ? "Stylesheet" - : event.includes("Dir") - ? "Directory" - : filename.match(/\/pages\/api\//) - ? "API Route" - : "Page"; - - await fullRebuild({ - msg: `${type} ${action}: ${filename}. Rebuilding ...`, - }); - } - } catch (error) { - log.error(`Watcher rebuild failed: ${error}`); - } finally { - if (owns_recompile) { - global.BUNEXT_RECOMPILING = false; - global.BUNEXT_IS_SERVER_COMPONENT = false; - } - } - }; - - watcher - .on("add", (path) => handleEvent("add", path)) - .on("change", (path) => handleEvent("change", path)) - .on("unlink", (path) => handleEvent("unlink", path)) - .on("addDir", (path) => handleEvent("addDir", path)) - .on("unlinkDir", (path) => handleEvent("unlinkDir", path)); -} - -function reloadWatcher() { - if (global.BUNEXT_PAGES_SRC_WATCHER) { - global.BUNEXT_PAGES_SRC_WATCHER.close(); - chokadirWatcherEsbuildCTX(); - } -} diff --git a/src/functions/server/cleanup-artifacts.tsx b/src/functions/server/cleanup-artifacts.tsx index fada23f..0ec1151 100644 --- a/src/functions/server/cleanup-artifacts.tsx +++ b/src/functions/server/cleanup-artifacts.tsx @@ -2,51 +2,111 @@ import { log } from "../../utils/log"; import path from "path"; import grabDirNames from "../../utils/grab-dir-names"; import { existsSync, readdirSync, statSync, unlinkSync } from "fs"; -import type { BundlerCTXMap } from "../../types"; type Params = { - new_artifacts: BundlerCTXMap[]; + paradigm: "hydration" | "ssr"; }; -const { ROOT_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE_NAME } = grabDirNames(); +const { ROOT_DIR, HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_DIR } = + grabDirNames(); + +// esbuild's content hashes are 8-char base64-ish (e.g. `CMSI7VSQ`). Entry +// outputs are `/.js` / `/.css`. Shared chunks are +// `chunk-*.js` and MUST be kept: any current entry may still reference an +// older chunk (esbuild reuses unchanged chunks across rebuilds), and +// verifying references would require parsing every entry's imports. +const ENTRY_HASH_FILE = /^[A-Za-z0-9]{8}\.(js|css)$/; + +/** + * Deletes orphaned entry outputs left behind by previous rebuilds. + * + * After a rebuild the page/SSR artifact maps only reference the newest + * hashed entry files; every previously hashed entry for the same directory + * is provably unreferenced (generated HTML always uses the current map) and + * is removed. Chunks, non-hashed fallback module files and `map.json` are + * left untouched. + */ +export default async function cleanupArtifacts({ paradigm }: Params) { + const is_hydration = paradigm === "hydration"; + const root_dir = is_hydration + ? HYDRATION_DST_DIR + : BUNX_CWD_MODULE_CACHE_DIR; + const map = is_hydration + ? global.BUNEXT_BUNDLER_CTX_MAP + : global.BUNEXT_SSR_BUNDLER_CTX_MAP; + + if (!map || !existsSync(root_dir)) { + return; + } -export default async function cleanupArtifacts({ new_artifacts }: Params) { try { - for (let i = 0; i < new_artifacts.length; i++) { - const new_artifact = new_artifacts[i]; + const keep_by_dir = new Map>(); - const artifact_public_dir = path.dirname( - path.join(ROOT_DIR, new_artifact.path), - ); + for (const entry of Object.values(map)) { + if (!entry?.path) continue; - const dir_content = readdirSync(artifact_public_dir); + const full_path = path.join(ROOT_DIR, entry.path); + const dir_path = path.dirname(full_path); + let keep = keep_by_dir.get(dir_path); + if (!keep) { + keep = new Set(); + keep_by_dir.set(dir_path, keep); + } - for (let d = 0; d < dir_content.length; d++) { - const dir_or_file = dir_content[d]; - const full_path = path.join(artifact_public_dir, dir_or_file); + keep.add(path.basename(full_path)); - const file_or_path_stats = statSync(full_path); - - if ( - file_or_path_stats.isDirectory() || - dir_or_file == HYDRATION_DST_DIR_MAP_JSON_FILE_NAME - ) { - continue; - } - - if ( - new_artifact.path.includes(dir_or_file) || - new_artifact.css_path?.includes(dir_or_file) - ) { - continue; - } - - if (existsSync(full_path)) { - unlinkSync(full_path); - } + if (entry.css_path) { + keep.add(path.basename(entry.css_path)); } } + + sweepDir(root_dir, keep_by_dir); } catch (error: any) { - log.error(error); + log.error(`Cleanup Artifacts Error: ${error?.message || error}`); + } +} + +function sweepDir( + dir_path: string, + keep_by_dir: Map>, +): void { + let entries: string[]; + try { + entries = readdirSync(dir_path); + } catch (error) { + return; + } + + for (const name of entries) { + const full_path = path.join(dir_path, name); + + let is_dir = false; + try { + is_dir = statSync(full_path).isDirectory(); + } catch (error) { + continue; + } + + if (is_dir) { + sweepDir(full_path, keep_by_dir); + continue; + } + + if (!name.match(ENTRY_HASH_FILE)) { + continue; + } + + if (name.startsWith("chunk-")) { + continue; + } + + const keep = keep_by_dir.get(path.dirname(full_path)); + if (keep?.has(name)) { + continue; + } + + try { + unlinkSync(full_path); + } catch (error) {} } } diff --git a/src/functions/server/full-rebuild.ts b/src/functions/server/full-rebuild.ts index 0d43dfc..29bfda9 100644 --- a/src/functions/server/full-rebuild.ts +++ b/src/functions/server/full-rebuild.ts @@ -51,8 +51,8 @@ async function doFullRebuild(params?: { msg?: string }) { } catch (error) {} await allPagesESBuildContextBundler({ - post_build_fn: async () => { - await serverPostBuildFn(); + post_build_fn: async (params) => { + await serverPostBuildFn(params); }, }); } catch (error: any) { diff --git a/src/functions/server/server-post-build-fn.ts b/src/functions/server/server-post-build-fn.ts index 872d23b..9f83594 100644 --- a/src/functions/server/server-post-build-fn.ts +++ b/src/functions/server/server-post-build-fn.ts @@ -4,6 +4,15 @@ import grabPageComponent from "./web-pages/grab-page-component"; type Params = { reload_all_controllers?: boolean; + /** + * Relative page paths whose client entry outputs changed in the last + * build. When provided with at least one entry, only controllers + * targeting those pages are re-rendered/re-pushed. When empty or + * omitted, every controller is refreshed (covers server-file-only + * changes, where hydration outputs are untouched but server props must + * still be re-fetched). + */ + changed_pages?: string[]; }; export default async function serverPostBuildFn(params?: Params) { @@ -11,6 +20,11 @@ export default async function serverPostBuildFn(params?: Params) { return; } + const changed_set = + params?.changed_pages && params.changed_pages.length > 0 + ? new Set(params.changed_pages) + : undefined; + const reload_payload = { reload: true }; const reload_enqueue = `event: update\ndata: ${JSON.stringify(reload_payload)}\n\n`; @@ -25,6 +39,11 @@ export default async function serverPostBuildFn(params?: Params) { continue; } + // Only touch controllers whose page actually changed. + if (changed_set && !changed_set.has(controller.target_map.local_path)) { + continue; + } + if (params?.reload_all_controllers) { try { controller.controller.enqueue(reload_enqueue); diff --git a/src/functions/server/watcher-esbuild-ctx.ts b/src/functions/server/watcher-esbuild-ctx.ts index 07e246c..81ad7fa 100644 --- a/src/functions/server/watcher-esbuild-ctx.ts +++ b/src/functions/server/watcher-esbuild-ctx.ts @@ -1,10 +1,12 @@ -import { watch, existsSync, statSync, glob } from "fs"; +import chokidar from "chokidar"; import path from "path"; +import { existsSync } from "fs"; import grabDirNames from "../../utils/grab-dir-names"; import fullRebuild from "./full-rebuild"; import { AppData } from "../../data/app-data"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; -import pagesSSRBundler from "../bundler/pages-ssr-bundler"; +import allPagesESBuildContextBundler from "../bundler/all-pages-esbuild-context-bundler"; +import serverPostBuildFn from "./server-post-build-fn"; import { log } from "../../utils/log"; import { logBuildErrorsOnce } from "../../utils/build-error-log"; @@ -24,7 +26,9 @@ export function restartWatcher() { if (Date.now() - last_watcher_restart < 1000) return; - global.BUNEXT_PAGES_SRC_WATCHER.close(); + try { + global.BUNEXT_PAGES_SRC_WATCHER.close(); + } catch (error) {} global.BUNEXT_PAGES_SRC_WATCHER = undefined; last_watcher_restart = Date.now(); watcherEsbuildCTX(); @@ -38,196 +42,264 @@ export function watcherRestartAvailable() { return Date.now() - last_watcher_restart > 6000; } -export default async function watcherEsbuildCTX() { - const pages_src_watcher = watch( - ROOT_DIR, - { - recursive: true, - persistent: true, - }, - async (event, filename) => { - let owns_recompile = false; +const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/; - try { - if (!filename) return; - const full_file_path = path.join(ROOT_DIR, filename); +// Rebuild coalescing: editors emit bursts of events per save (change + +// atomic-swap noise), and edits can land while a rebuild is running. +// Events are debounced into a single runner; the runner loops until the +// event queue drains, so a mid-build edit is never silently dropped. +const REBUILD_DEBOUNCE_MS = 200; +let pending_structural = false; +let pending_content_file: string | undefined = undefined; +let debounce_timer: ReturnType | undefined = undefined; - if (global.BUNEXT_CONFIG.exclude_watch_patterns) { - for ( - let i = 0; - i < global.BUNEXT_CONFIG.exclude_watch_patterns.length; - i++ - ) { - const watch_pattern = - global.BUNEXT_CONFIG.exclude_watch_patterns[i]; +function scheduleRebuild() { + if (debounce_timer) { + clearTimeout(debounce_timer); + } + debounce_timer = setTimeout(async () => { + debounce_timer = undefined; + try { + await runRebuilds(); + } catch (error: any) { + const msg = String(error?.message || error || ""); + if (!msg.startsWith("Build failed with")) { + logBuildErrorsOnce( + [{ text: `Watcher rebuild failed: ${msg}` }], + (line) => log.error(line), + ); + } + } finally { + global.BUNEXT_RECOMPILING = false; + global.BUNEXT_IS_SERVER_COMPONENT = false; + } + }, REBUILD_DEBOUNCE_MS); +} - if (watch_pattern instanceof RegExp) { - const is_path_excluded = - watch_pattern.test(filename); - if (is_path_excluded) { - return; - } - } else { - const excluded_path = path.resolve( - ROOT_DIR, - watch_pattern, - ); +/** + * Processes the pending event batch. Structural changes go through the + * all-pages bundler (which re-creates esbuild contexts only when the entry + * set really changed, so atomic-swap saves resolve to incremental rebuilds). + * Content changes rebuild the hydration context incrementally; its artifact + * tracker then queues the single-flighted SSR rebuild and pushes HMR updates. + */ +async function runRebuilds() { + if (global.BUNEXT_RECOMPILING) { + return; + } + global.BUNEXT_RECOMPILING = true; + global.BUNEXT_IS_SERVER_COMPONENT = false; - if (excluded_path == full_file_path) { - return; - } - } - } - } + try { + while (true) { + const structural = pending_structural; + const content_file = pending_content_file; + pending_structural = false; + pending_content_file = undefined; - if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) { - await fullRebuild(); - return; - } + if (!structural && !content_file) { + break; + } - if (filename.match(/^\.\w+/)) { - return; - } - - if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { - await fullRebuild({ msg: `Restarting Bundler ...` }); - return; - } - - if (global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED) { - await pagesSSRBundler().catch((error) => { - log.error(`SSR Bundler Error: ${error}`); - }); - } - - if (filename.endsWith(AppData["BunextTmpFileExt"])) { - return; - } - - const does_file_exist = existsSync(full_file_path); - const file_stat = does_file_exist - ? statSync(full_file_path) - : undefined; - - if (full_file_path.match(/\/styles$/)) { - owns_recompile = true; - global.BUNEXT_RECOMPILING = true; - await Bun.sleep(1000); - await fullRebuild({ - msg: `Detected new \`styles\` directory. Rebuilding ...`, - }); - return; - } - - const excluded_match = - /node_modules\/|^public\/|^\.bunext\/|^\.git\/|^\.?dist\/|bun\.lockb$/; - - if (filename.match(excluded_match)) return; - - if (filename.match(/bunext.config\.ts/)) { - await fullRebuild({ - msg: `bunext.config.ts file changed. Rebuilding server ...`, - }); - return; - } - - const target_files_match = /\.(tsx?|jsx?|css)$/; - - if (event !== "rename") { - if (filename.match(target_files_match)) { - if (global.BUNEXT_RECOMPILING) return; - owns_recompile = true; - global.BUNEXT_RECOMPILING = true; - - if (filename.match(/.*\.server\.tsx?/)) { - global.BUNEXT_IS_SERVER_COMPONENT = true; - } - - if ( - global.BUNEXT_BUNDLER_CTX && - !global.BUNEXT_BUNDLER_CTX_DISPOSED - ) { - await global.BUNEXT_BUNDLER_CTX.rebuild(); - } else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { - await fullRebuild({ - msg: `Restarting Bundler ...`, - }); - return; - } - - if (filename.match(/(404|500)\.tsx?/)) { - 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 is_file_of_interest = - Boolean(filename.match(target_files_match)) || - file_stat?.isDirectory(); - - if (!is_file_of_interest) { - return; - } - - if (!filename.match(/^src\/pages\/|\.css$/)) - return reloadWatcher(); - if (checkExcludedPatterns({ path: filename })) - return reloadWatcher(); - if (filename.match(/ /)) return reloadWatcher(); - - if (global.BUNEXT_RECOMPILING) return; - - owns_recompile = true; - const action = does_file_exist ? "created" : "deleted"; - const type = filename.match(/\.css$/) - ? "Sylesheet" - : file_stat?.isDirectory() - ? "Directory" - : filename.match(/\/pages\/api\//) - ? "API Route" - : "Page"; - - await fullRebuild({ - msg: `${type} ${action}: ${filename}. Rebuilding ...`, + if (structural) { + global.BUNEXT_ROUTER.reload(); + await allPagesESBuildContextBundler({ + post_build_fn: async (params) => { + await serverPostBuildFn(params); + }, }); - } catch (error: any) { - // A "Build failed with N errors" rejection is the same - // failure the tracker's onEnd already reported (deduped). - const msg = String(error?.message || error || ""); - if (!msg.startsWith("Build failed with")) { - logBuildErrorsOnce( - [{ text: `Watcher rebuild failed: ${msg}` }], - (line) => log.error(line), - ); + } else if (content_file) { + if ( + global.BUNEXT_BUNDLER_CTX && + !global.BUNEXT_BUNDLER_CTX_DISPOSED + ) { + await global.BUNEXT_BUNDLER_CTX.rebuild(); + } else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { + await fullRebuild({ + msg: `Restarting Bundler ...`, + }); + break; } - } finally { - if (owns_recompile) { - global.BUNEXT_RECOMPILING = false; - global.BUNEXT_IS_SERVER_COMPONENT = false; + + if (content_file.match(/(404|500)\.tsx?$/)) { + 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); + } + } } } - }, + } + } finally { + global.BUNEXT_RECOMPILING = false; + global.BUNEXT_IS_SERVER_COMPONENT = false; + } +} + +function isPathExcluded(filename: string, full_file_path: string): boolean { + const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns; + if (!user_patterns) return false; + + for (let i = 0; i < user_patterns.length; i++) { + const watch_pattern = user_patterns[i]; + if (watch_pattern instanceof RegExp) { + if (watch_pattern.test(filename)) return true; + } else { + const excluded_path = path.resolve(ROOT_DIR, watch_pattern); + if (excluded_path == full_file_path) return true; + } + } + return false; +} + +// Heavy directories are filtered at the watcher level so inotify doesn't +// install watches (and deliver events) for trees the bundler never reads. +function buildIgnoredPatterns() { + const ignored: Array boolean)> = [ + /(^|[\/\\])\../, + /(^|[\/\\])node_modules([\/\\]|$)/, + /(^|[\/\\])public([\/\\]|$)/, + /(^|[\/\\])\.bunext([\/\\]|$)/, + /(^|[\/\\])\.git([\/\\]|$)/, + /(^|[\/\\])dist([\/\\]|$)/, + /bun\.lockb$/, + (p: string) => p.endsWith(AppData["BunextTmpFileExt"]), + ]; + + const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns; + if (user_patterns) { + for (let i = 0; i < user_patterns.length; i++) { + const pattern = user_patterns[i]; + if (pattern instanceof RegExp) { + ignored.push((p: string) => + pattern.test(path.relative(ROOT_DIR, p)), + ); + } else { + const excluded_path = path.resolve(ROOT_DIR, pattern); + ignored.push((p: string) => p === excluded_path); + } + } + } + + return ignored; +} + +export default async function watcherEsbuildCTX() { + const pages_src_watcher = chokidar.watch(ROOT_DIR, { + ignored: buildIgnoredPatterns(), + persistent: true, + ignoreInitial: true, + }); + + watcherHandlers( + pages_src_watcher, + (event, filePath) => handleEvent(event, filePath), ); - global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher; + global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher as any; } -function reloadWatcher() { - restartWatcher(); +function handleEvent( + event: "add" | "change" | "unlink" | "addDir" | "unlinkDir", + filePath: string, +) { + try { + const filename = path.relative(ROOT_DIR, filePath); + const full_file_path = filePath; + + if (isPathExcluded(filename, full_file_path)) { + return; + } + + if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) { + fullRebuild(); + return; + } + + if (filename.endsWith(AppData["BunextTmpFileExt"])) { + return; + } + + if (full_file_path.match(/\/styles$/)) { + pending_structural = false; + pending_content_file = undefined; + if (debounce_timer) clearTimeout(debounce_timer); + setTimeout(() => { + fullRebuild({ + msg: `Detected new \`styles\` directory. Rebuilding ...`, + }); + }, 1000); + return; + } + + if (filename.match(/bunext\.config\.ts/)) { + pending_structural = false; + pending_content_file = undefined; + if (debounce_timer) clearTimeout(debounce_timer); + fullRebuild({ + msg: `bunext.config.ts file changed. Rebuilding server ...`, + }); + return; + } + + if (event === "change") { + if (filename.match(TARGET_FILES_MATCH)) { + pending_content_file = full_file_path; + scheduleRebuild(); + } + return; + } + + // add / unlink / addDir / unlinkDir + const is_dir_event = event === "addDir" || event === "unlinkDir"; + const is_file_of_interest = + Boolean(filename.match(TARGET_FILES_MATCH)) || is_dir_event; + + if (!is_file_of_interest) { + return; + } + + // Only page routes and stylesheets are structural; anything else + // (e.g. a component being added) is covered by the next content + // rebuild and needs no context recreation. + if ( + !filename.match(/^src\/pages\/|\.css$/) || + checkExcludedPatterns({ path: filename }) || + filename.match(/ /) + ) { + return; + } + + pending_structural = true; + scheduleRebuild(); + } catch (error: any) { + logBuildErrorsOnce( + [{ text: `Watcher error: ${error?.message || error}` }], + (line) => log.error(line), + ); + } +} + +function watcherHandlers( + watcher: ReturnType, + handleEvent: ( + event: "add" | "change" | "unlink" | "addDir" | "unlinkDir", + filePath: string, + ) => void, +) { + watcher + .on("add", (p) => handleEvent("add", p)) + .on("change", (p) => handleEvent("change", p)) + .on("unlink", (p) => handleEvent("unlink", p)) + .on("addDir", (p) => handleEvent("addDir", p)) + .on("unlinkDir", (p) => handleEvent("unlinkDir", p)); } diff --git a/src/functions/server/web-pages/handle-web-pages.tsx b/src/functions/server/web-pages/handle-web-pages.tsx index f418a1a..366d4aa 100644 --- a/src/functions/server/web-pages/handle-web-pages.tsx +++ b/src/functions/server/web-pages/handle-web-pages.tsx @@ -34,6 +34,15 @@ export default async function handleWebPages({ } } + // The dev server port opens before the one-time startup build + // finishes. First page render waits for it so we never serve HTML + // against half-built hydration/SSR modules. + if (is_dev && global.BUNEXT_INITIAL_BUILD_PROMISE) { + try { + await global.BUNEXT_INITIAL_BUILD_PROMISE; + } catch (error) {} + } + const componentRes = await grabPageComponent({ req, });