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 { cssStubEsbuildPlugin } 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 cleanupArtifacts from "../server/cleanup-artifacts"; import { evictDeletedModules } from "../../utils/import-fresh"; import { writeFileSync } from "fs"; import path from "path"; import { log } from "../../utils/log"; import { logBuildErrorsOnce } from "../../utils/build-error-log"; import shouldMinifySSR from "../../utils/should-minify-ssr"; 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; } 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\//) || 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 }); } 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; } }); rebuild_in_flight = run; return run; } /** * Resolves once the in-flight SSR rebuild (if any) has finished. Lets page * requests avoid rendering stale SSR modules against a newer client bundle. */ export async function awaitSSRRebuild() { try { await rebuild_in_flight; } catch (error) { } } 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: shouldMinifySSR({ minify_ssr_bundle: config.minify_ssr_bundle, }), format: "esm", target: "esnext", platform: "node", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), }, entryNames: "[dir]/[hash]", metafile: true, plugins: [ // SSR CSS output is never served (HTML links the // hydration build's CSS), so skip Tailwind here. cssStubEsbuildPlugin, 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" }); // esbuild has now replaced superseded outputs on disk; release the // ones the server already imported. evictDeletedModules(BUNX_CWD_MODULE_CACHE_DIR); } 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)); } } }