From 4d4a79a6ddc3b0bf96e386603049267bf9235125 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 5 Nov 2026 07:32:33 +0100 Subject: [PATCH] Push HMR updates before the SSR rebuild in dev Client-only edits are sent to the browser right after the hydration build, without re-running server functions; SSR rebuilds afterwards and props are re-fetched only when a .server.ts output changed or after fullRebuild. Page requests wait for an in-flight SSR rebuild. The SSR build no longer runs Tailwind, the PostCSS processor is reused across rebuilds, and the watcher debounce drops from 200ms to 50ms. Co-Authored-By: Claude Opus 5.5 --- .../all-pages-esbuild-context-bundler.d.ts | 5 + .../all-pages-esbuild-context-bundler.js | 1 + .../bundler/pages-ssr-context-bundler.d.ts | 5 + .../bundler/pages-ssr-context-bundler.js | 16 ++- .../plugins/esbuild-ctx-artifact-tracker.d.ts | 8 +- .../plugins/esbuild-ctx-artifact-tracker.js | 55 +++++++- dist/functions/server/full-rebuild.js | 1 + .../server/server-post-build-fn.d.ts | 9 ++ dist/functions/server/server-post-build-fn.js | 34 +++++ dist/functions/server/watcher-esbuild-ctx.js | 2 +- .../server/web-pages/handle-web-pages.js | 6 + .../web-pages/tailwind-esbuild-plugin.d.ts | 13 ++ .../web-pages/tailwind-esbuild-plugin.js | 89 +++++++------ package.json | 2 +- .../server/server-post-build-fn.test.ts | 55 +++++++- .../all-pages-esbuild-context-bundler.ts | 6 + .../bundler/pages-ssr-context-bundler.ts | 16 ++- .../plugins/esbuild-ctx-artifact-tracker.ts | 65 +++++++++- src/functions/server/full-rebuild.ts | 1 + src/functions/server/server-post-build-fn.ts | 42 +++++++ src/functions/server/watcher-esbuild-ctx.ts | 2 +- .../server/web-pages/handle-web-pages.tsx | 7 ++ .../web-pages/tailwind-esbuild-plugin.tsx | 118 +++++++++++------- 23 files changed, 460 insertions(+), 98 deletions(-) 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 dcb14e1..2e40b68 100644 --- a/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts +++ b/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts @@ -6,6 +6,11 @@ type Params = { }) => Promise | void; build_only?: boolean; start?: boolean; + /** + * Re-fetch server props for every HMR client after the build, even if + * only client code changed. Only applies when the context is recreated. + */ + refresh_props?: boolean; }; /** * True when `local_path` is a hydration entry of the current esbuild context. diff --git a/dist/functions/bundler/all-pages-esbuild-context-bundler.js b/dist/functions/bundler/all-pages-esbuild-context-bundler.js index 31c8d2e..5fd65e8 100644 --- a/dist/functions/bundler/all-pages-esbuild-context-bundler.js +++ b/dist/functions/bundler/all-pages-esbuild-context-bundler.js @@ -88,6 +88,7 @@ export default async function allPagesESBuildContextBundler(params) { entryToPage, post_build_fn: params?.post_build_fn, build_only: params?.build_only || params?.start, + refresh_props_on_first_build: params?.refresh_props, }), ], jsx: "automatic", diff --git a/dist/functions/bundler/pages-ssr-context-bundler.d.ts b/dist/functions/bundler/pages-ssr-context-bundler.d.ts index f76464c..f610a52 100644 --- a/dist/functions/bundler/pages-ssr-context-bundler.d.ts +++ b/dist/functions/bundler/pages-ssr-context-bundler.d.ts @@ -15,4 +15,9 @@ type RebuildParams = { * hydration context rebuild, which re-enters this queue). */ export default function enqueueSSRRebuild(params?: RebuildParams): Promise; +/** + * 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 declare function awaitSSRRebuild(): Promise; export {}; diff --git a/dist/functions/bundler/pages-ssr-context-bundler.js b/dist/functions/bundler/pages-ssr-context-bundler.js index 4bb431a..809bd58 100644 --- a/dist/functions/bundler/pages-ssr-context-bundler.js +++ b/dist/functions/bundler/pages-ssr-context-bundler.js @@ -2,7 +2,7 @@ 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 { 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"; @@ -87,6 +87,16 @@ export default async function enqueueSSRRebuild(params) { 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(); @@ -137,7 +147,9 @@ async function doSSRRebuild(params) { entryNames: "[dir]/[hash]", metafile: true, plugins: [ - tailwindEsbuildPlugin, + // SSR CSS output is never served (HTML links the + // hydration build's CSS), so skip Tailwind here. + cssStubEsbuildPlugin, ssrVirtualFilesPlugin({ getEntryToPage: () => current_entry_map, }), 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 6e3649e..7e373d8 100644 --- a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.d.ts +++ b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.d.ts @@ -9,6 +9,12 @@ type Params = { changed_pages?: string[]; }) => Promise | void; build_only?: boolean; + /** + * Always re-fetch server props after this context's first build, even + * when only client code changed (set by `fullRebuild`, e.g. after a + * config change). + */ + refresh_props_on_first_build?: boolean; }; -export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, }: Params): Plugin; +export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }: Params): Plugin; export {}; diff --git a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js index 1578ef2..7a584d4 100644 --- a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js +++ b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js @@ -11,9 +11,11 @@ import path from "path"; import cleanupLogsDirs from "../../cleanup-logs-dir"; import grabRelativePath from "../../../utils/grab-relative-path"; import cleanupArtifacts from "../../server/cleanup-artifacts"; +import { pushClientUpdate } from "../../server/server-post-build-fn"; const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames(); let build_start = 0; -export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, }) { +export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }) { + let force_props_refresh = Boolean(refresh_props_on_first_build); const artifactTracker = { name: "artifact-tracker", setup(build) { @@ -78,9 +80,12 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, } global.BUNEXT_BUILD_FAILED = false; global.BUNEXT_BUNDLER_CTX_DISPOSED = false; + const props_refresh_forced = force_props_refresh; + force_props_refresh = false; const does_error_file_exist = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE); - // SSR must finish before HMR so server props are fresh. - // The rebuild is single-flighted: concurrent hydration + // Client-only changes are pushed before the SSR rebuild; + // anything that may change server props waits for SSR so + // props are fresh. The rebuild is single-flighted: concurrent hydration // rebuilds join the in-flight SSR rebuild instead of // starting overlapping ones. if (build_only) { @@ -98,6 +103,35 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, cleanupLogsDirs(); await fullRebuild(); } + else if (changed_pages.length > 0 && + post_build_fn && + !props_refresh_forced) { + // Client graph changed: push the new bundle right away. + // Server functions live in `.server.ts` files, outside + // the client graph, so the browser's current props are + // still valid and it needn't wait for the SSR rebuild. + pushClientUpdate(changed_pages); + const server_hashes_before = grabServerEntryPaths(); + try { + await enqueueSSRRebuild(); + } + catch (error) { + log.error(`SSR Bundler Error: ${error}`); + } + // A module shared by a page and its server file changed: + // props may differ, so re-fetch them for every tab. + if (server_hashes_before !== grabServerEntryPaths()) { + try { + await post_build_fn({ + artifacts: artifacts || [], + changed_pages: [], + }); + } + catch (error) { + log.error(`Post-build Error: ${error}`); + } + } + } else { try { await enqueueSSRRebuild(); @@ -126,3 +160,18 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, }; return artifactTracker; } +/** + * Fingerprint of the SSR outputs of all `.server.ts(x)` entries. Outputs are + * content-hashed, so a changed fingerprint means some server function's code + * (or a module it imports) changed. + */ +function grabServerEntryPaths() { + const map = global.BUNEXT_SSR_BUNDLER_CTX_MAP; + if (!map) + return ""; + return Object.keys(map) + .filter((key) => key.match(/\.server\.tsx?$/)) + .sort() + .map((key) => `${key}:${map[key]?.path || ""}`) + .join("\n"); +} diff --git a/dist/functions/server/full-rebuild.js b/dist/functions/server/full-rebuild.js index b12cdb6..05adc1a 100644 --- a/dist/functions/server/full-rebuild.js +++ b/dist/functions/server/full-rebuild.js @@ -37,6 +37,7 @@ async function doFullRebuild(params) { global.BUNEXT_ROUTER.reload(); await disposeBundlerContexts(); await allPagesESBuildContextBundler({ + refresh_props: true, 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 ff16a18..cbde0d6 100644 --- a/dist/functions/server/server-post-build-fn.d.ts +++ b/dist/functions/server/server-post-build-fn.d.ts @@ -10,5 +10,14 @@ type Params = { */ changed_pages?: string[]; }; +/** + * # Push a client-only HMR update + * Sends the new client bundle to every controller whose page is in + * `changed_pages`, without re-running server functions. The payload carries + * no `page_props`, so the browser keeps its current props and only swaps the + * hydration script/CSS. Used right after the hydration build so the browser + * doesn't wait for the SSR rebuild. + */ +export declare function pushClientUpdate(changed_pages: string[]): void; 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 e505ea0..eac3f28 100644 --- a/dist/functions/server/server-post-build-fn.js +++ b/dist/functions/server/server-post-build-fn.js @@ -1,5 +1,39 @@ import _ from "lodash"; import grabPageComponent from "./web-pages/grab-page-component"; +/** + * # Push a client-only HMR update + * Sends the new client bundle to every controller whose page is in + * `changed_pages`, without re-running server functions. The payload carries + * no `page_props`, so the browser keeps its current props and only swaps the + * hydration script/CSS. Used right after the hydration build so the browser + * doesn't wait for the SSR rebuild. + */ +export function pushClientUpdate(changed_pages) { + if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) { + return; + } + const changed_set = new Set(changed_pages); + for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) { + const controller = global.BUNEXT_HMR_CONTROLLERS[i]; + const local_path = controller?.target_map?.local_path; + if (!controller || !local_path || !changed_set.has(local_path)) { + continue; + } + const target_artifact = global.BUNEXT_BUNDLER_CTX_MAP[local_path]; + const payload = target_artifact?.path + ? { target_map: target_artifact } + : { reload: true }; + try { + controller.controller.enqueue(`event: update\ndata: ${JSON.stringify(payload)}\n\n`); + if (target_artifact?.path) { + controller.target_map = target_artifact; + } + } + catch { + global.BUNEXT_HMR_CONTROLLERS.splice(i, 1); + } + } +} export default async function serverPostBuildFn(params) { if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) { return; diff --git a/dist/functions/server/watcher-esbuild-ctx.js b/dist/functions/server/watcher-esbuild-ctx.js index 1a18b39..39f84b2 100644 --- a/dist/functions/server/watcher-esbuild-ctx.js +++ b/dist/functions/server/watcher-esbuild-ctx.js @@ -83,7 +83,7 @@ const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/; // 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; +const REBUILD_DEBOUNCE_MS = 50; let pending_structural = false; let pending_content_file = undefined; let debounce_timer = undefined; diff --git a/dist/functions/server/web-pages/handle-web-pages.js b/dist/functions/server/web-pages/handle-web-pages.js index 13978ae..7909843 100644 --- a/dist/functions/server/web-pages/handle-web-pages.js +++ b/dist/functions/server/web-pages/handle-web-pages.js @@ -5,6 +5,7 @@ import getCache from "../../cache/get-cache"; import generateWebPageResponseFromComponentReturn from "./generate-web-page-response-from-component-return"; import grabPageComponent from "./grab-page-component"; import grabPageErrorComponent from "./grab-page-error-component"; +import { awaitSSRRebuild } from "../../bundler/pages-ssr-context-bundler"; export default async function handleWebPages({ req, }) { try { const is_dev = isDevelopment(); @@ -31,6 +32,11 @@ export default async function handleWebPages({ req, }) { } catch (error) { } } + // HMR pushes client updates before the SSR rebuild finishes; a page + // load in that window waits so SSR HTML matches the new bundle. + if (is_dev) { + await awaitSSRRebuild(); + } const componentRes = await grabPageComponent({ req, }); diff --git a/dist/functions/server/web-pages/tailwind-esbuild-plugin.d.ts b/dist/functions/server/web-pages/tailwind-esbuild-plugin.d.ts index baff1e5..80ea811 100644 --- a/dist/functions/server/web-pages/tailwind-esbuild-plugin.d.ts +++ b/dist/functions/server/web-pages/tailwind-esbuild-plugin.d.ts @@ -1,3 +1,16 @@ import * as esbuild from "esbuild"; +type Params = { + /** + * Run `.css` files through Tailwind/PostCSS. When `false`, every `.css` + * import loads as an empty stylesheet (for builds whose CSS output is + * never used, e.g. SSR). + */ + process_css?: boolean; +}; +export declare function createTailwindEsbuildPlugin({ process_css, }?: Params): esbuild.Plugin; +/** + * Resolves `.css` imports like the Tailwind plugin but loads them empty. + */ +export declare const cssStubEsbuildPlugin: esbuild.Plugin; declare const tailwindEsbuildPlugin: esbuild.Plugin; export default tailwindEsbuildPlugin; diff --git a/dist/functions/server/web-pages/tailwind-esbuild-plugin.js b/dist/functions/server/web-pages/tailwind-esbuild-plugin.js index fabd197..54e5eca 100644 --- a/dist/functions/server/web-pages/tailwind-esbuild-plugin.js +++ b/dist/functions/server/web-pages/tailwind-esbuild-plugin.js @@ -8,42 +8,55 @@ import grabDirNames from "../../../utils/grab-dir-names"; import { log } from "../../../utils/log"; const { ROOT_DIR } = grabDirNames(); let error_logged = false; -const tailwindEsbuildPlugin = { - name: "tailwindcss", - setup(build) { - build.onLoad({ filter: /\.css$/ }, async (args) => { - try { - const source = await readFile(args.path, "utf-8"); - const result = await postcss([tailwindcss()]).process(source, { - from: args.path, - }); - error_logged = false; - return { contents: result.css, loader: "css" }; - } - catch (error) { - return { errors: [{ text: error.message }] }; - } - }); - build.onResolve({ filter: /\.css$/ }, async (args) => { - const css_path = path.resolve(args.resolveDir, args.path.replace(/\@\//g, ROOT_DIR + "/")); - const does_path_exist = existsSync(css_path); - if (!does_path_exist && !error_logged) { - const err_msg = `CSS Error: ${css_path} file does not exist.`; - log.error(err_msg); - error_logged = true; - // return { - // errors: [ - // { - // text: err_msg, - // }, - // ], - // pluginName: "tailwindcss", - // }; - } - return { - path: css_path, - }; - }); - }, -}; +// One processor for the whole process, so the Tailwind plugin's internal +// state survives across rebuilds instead of being recreated per file load. +let css_processor; +function grabCSSProcessor() { + if (!css_processor) { + css_processor = postcss([tailwindcss()]); + } + return css_processor; +} +export function createTailwindEsbuildPlugin({ process_css = true, } = {}) { + return { + name: process_css ? "tailwindcss" : "css-stub", + setup(build) { + build.onLoad({ filter: /\.css$/ }, async (args) => { + if (!process_css) { + return { contents: "", loader: "css" }; + } + try { + const source = await readFile(args.path, "utf-8"); + const result = await grabCSSProcessor().process(source, { + from: args.path, + }); + error_logged = false; + return { contents: result.css, loader: "css" }; + } + catch (error) { + return { errors: [{ text: error.message }] }; + } + }); + build.onResolve({ filter: /\.css$/ }, async (args) => { + const css_path = path.resolve(args.resolveDir, args.path.replace(/\@\//g, ROOT_DIR + "/")); + const does_path_exist = existsSync(css_path); + if (!does_path_exist && !error_logged) { + const err_msg = `CSS Error: ${css_path} file does not exist.`; + log.error(err_msg); + error_logged = true; + } + return { + path: css_path, + }; + }); + }, + }; +} +/** + * Resolves `.css` imports like the Tailwind plugin but loads them empty. + */ +export const cssStubEsbuildPlugin = createTailwindEsbuildPlugin({ + process_css: false, +}); +const tailwindEsbuildPlugin = createTailwindEsbuildPlugin(); export default tailwindEsbuildPlugin; diff --git a/package.json b/package.json index 071cf6c..ce92eeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@moduletrace/bunext", - "version": "1.3.4", + "version": "1.3.5", "main": "dist/index.js", "module": "index.ts", "dependencies": { diff --git a/src/__tests__/functions/server/server-post-build-fn.test.ts b/src/__tests__/functions/server/server-post-build-fn.test.ts index 5ede0e3..d5542f5 100644 --- a/src/__tests__/functions/server/server-post-build-fn.test.ts +++ b/src/__tests__/functions/server/server-post-build-fn.test.ts @@ -3,7 +3,9 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; // NOTE: this file runs the REAL grabPageComponent (no module mock). The // callers use `return_server_res_only` + `no_retry`, so no bundling or // rebuilds happen as long as the fake router + artifact map are set up. -import serverPostBuildFn from "../../../functions/server/server-post-build-fn"; +import serverPostBuildFn, { + pushClientUpdate, +} from "../../../functions/server/server-post-build-fn"; function makeController(page_url: string, target_map?: { local_path: string }) { const enqueued: string[] = []; @@ -146,4 +148,53 @@ describe("server-post-build-fn", () => { expect(changed.enqueued.length).toBe(1); expect(untouched.enqueued.length).toBe(0); }); -}); \ No newline at end of file + + describe("pushClientUpdate", () => { + test("pushes the new bundle without page_props to changed pages only", () => { + const changed = makeController("http://localhost/about", { + local_path: "pages/about.tsx", + }); + const untouched = makeController("http://localhost/missing", { + local_path: "pages/404.tsx", + }); + global.BUNEXT_HMR_CONTROLLERS = [changed as any, untouched as any]; + + pushClientUpdate(["pages/about.tsx"]); + + expect(changed.enqueued.length).toBe(1); + const data = JSON.parse( + changed.enqueued[0]!.replace(/^event: update\ndata: /, ""), + ); + expect(data.target_map.path).toBe("pages/about.js"); + expect(data.page_props).toBeUndefined(); + expect(data.reload).toBeUndefined(); + expect(untouched.enqueued.length).toBe(0); + }); + + test("reloads a changed page that has no bundled artifact", () => { + const c = makeController("http://localhost/new-page", { + local_path: "pages/new-page.tsx", + }); + global.BUNEXT_HMR_CONTROLLERS = [c as any]; + + pushClientUpdate(["pages/new-page.tsx"]); + + expect(c.enqueued.length).toBe(1); + expect(c.enqueued[0]).toContain('"reload":true'); + }); + + test("drops controllers whose stream is closed", () => { + const c = makeController("http://localhost/about", { + local_path: "pages/about.tsx", + }); + c.controller.enqueue = () => { + throw new Error("closed"); + }; + global.BUNEXT_HMR_CONTROLLERS = [c as any]; + + pushClientUpdate(["pages/about.tsx"]); + + expect(global.BUNEXT_HMR_CONTROLLERS.length).toBe(0); + }); + }); +}); diff --git a/src/functions/bundler/all-pages-esbuild-context-bundler.ts b/src/functions/bundler/all-pages-esbuild-context-bundler.ts index 59620fe..ca38344 100644 --- a/src/functions/bundler/all-pages-esbuild-context-bundler.ts +++ b/src/functions/bundler/all-pages-esbuild-context-bundler.ts @@ -23,6 +23,11 @@ type Params = { }) => Promise | void; build_only?: boolean; start?: boolean; + /** + * Re-fetch server props for every HMR client after the build, even if + * only client code changed. Only applies when the context is recreated. + */ + refresh_props?: boolean; }; type HydrationVirtualEntry = PageFiles & { tsx: string }; @@ -124,6 +129,7 @@ export default async function allPagesESBuildContextBundler(params?: Params) { entryToPage, post_build_fn: params?.post_build_fn, build_only: params?.build_only || params?.start, + refresh_props_on_first_build: params?.refresh_props, }), ], jsx: "automatic", diff --git a/src/functions/bundler/pages-ssr-context-bundler.ts b/src/functions/bundler/pages-ssr-context-bundler.ts index 532ce76..dbec0ef 100644 --- a/src/functions/bundler/pages-ssr-context-bundler.ts +++ b/src/functions/bundler/pages-ssr-context-bundler.ts @@ -2,7 +2,7 @@ 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 { cssStubEsbuildPlugin } 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"; @@ -120,6 +120,16 @@ export default async function enqueueSSRRebuild(params?: RebuildParams) { 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?: RebuildParams) { const force = Boolean(params?.force); const dev = isDevelopment(); @@ -187,7 +197,9 @@ async function doSSRRebuild(params?: RebuildParams) { entryNames: "[dir]/[hash]", metafile: true, plugins: [ - tailwindEsbuildPlugin, + // SSR CSS output is never served (HTML links the + // hydration build's CSS), so skip Tailwind here. + cssStubEsbuildPlugin, ssrVirtualFilesPlugin({ getEntryToPage: () => current_entry_map, }), diff --git a/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts b/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts index 9613222..a682e98 100644 --- a/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts +++ b/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts @@ -15,6 +15,7 @@ import path from "path"; import cleanupLogsDirs from "../../cleanup-logs-dir"; import grabRelativePath from "../../../utils/grab-relative-path"; import cleanupArtifacts from "../../server/cleanup-artifacts"; +import { pushClientUpdate } from "../../server/server-post-build-fn"; const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames(); @@ -32,13 +33,22 @@ type Params = { changed_pages?: string[]; }) => Promise | void; build_only?: boolean; + /** + * Always re-fetch server props after this context's first build, even + * when only client code changed (set by `fullRebuild`, e.g. after a + * config change). + */ + refresh_props_on_first_build?: boolean; }; export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, + refresh_props_on_first_build, }: Params) { + let force_props_refresh = Boolean(refresh_props_on_first_build); + const artifactTracker: Plugin = { name: "artifact-tracker", setup(build) { @@ -135,12 +145,16 @@ export default function esbuildCTXArtifactTracker({ global.BUNEXT_BUILD_FAILED = false; global.BUNEXT_BUNDLER_CTX_DISPOSED = false; + const props_refresh_forced = force_props_refresh; + force_props_refresh = false; + const does_error_file_exist = existsSync( BUNX_BUNDLER_ERROR_EXIT_FILE, ); - // SSR must finish before HMR so server props are fresh. - // The rebuild is single-flighted: concurrent hydration + // Client-only changes are pushed before the SSR rebuild; + // anything that may change server props waits for SSR so + // props are fresh. The rebuild is single-flighted: concurrent hydration // rebuilds join the in-flight SSR rebuild instead of // starting overlapping ones. if (build_only) { @@ -158,6 +172,37 @@ export default function esbuildCTXArtifactTracker({ rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true }); cleanupLogsDirs(); await fullRebuild(); + } else if ( + changed_pages.length > 0 && + post_build_fn && + !props_refresh_forced + ) { + // Client graph changed: push the new bundle right away. + // Server functions live in `.server.ts` files, outside + // the client graph, so the browser's current props are + // still valid and it needn't wait for the SSR rebuild. + pushClientUpdate(changed_pages); + + const server_hashes_before = grabServerEntryPaths(); + + try { + await enqueueSSRRebuild(); + } catch (error) { + log.error(`SSR Bundler Error: ${error}`); + } + + // A module shared by a page and its server file changed: + // props may differ, so re-fetch them for every tab. + if (server_hashes_before !== grabServerEntryPaths()) { + try { + await post_build_fn({ + artifacts: artifacts || [], + changed_pages: [], + }); + } catch (error) { + log.error(`Post-build Error: ${error}`); + } + } } else { try { await enqueueSSRRebuild(); @@ -188,3 +233,19 @@ export default function esbuildCTXArtifactTracker({ return artifactTracker; } + +/** + * Fingerprint of the SSR outputs of all `.server.ts(x)` entries. Outputs are + * content-hashed, so a changed fingerprint means some server function's code + * (or a module it imports) changed. + */ +function grabServerEntryPaths(): string { + const map = global.BUNEXT_SSR_BUNDLER_CTX_MAP; + if (!map) return ""; + + return Object.keys(map) + .filter((key) => key.match(/\.server\.tsx?$/)) + .sort() + .map((key) => `${key}:${map[key]?.path || ""}`) + .join("\n"); +} diff --git a/src/functions/server/full-rebuild.ts b/src/functions/server/full-rebuild.ts index 9837b46..44c7770 100644 --- a/src/functions/server/full-rebuild.ts +++ b/src/functions/server/full-rebuild.ts @@ -47,6 +47,7 @@ async function doFullRebuild(params?: { msg?: string }) { await disposeBundlerContexts(); await allPagesESBuildContextBundler({ + refresh_props: true, post_build_fn: async (params) => { await serverPostBuildFn(params); }, diff --git a/src/functions/server/server-post-build-fn.ts b/src/functions/server/server-post-build-fn.ts index fa354a2..0ba1bc3 100644 --- a/src/functions/server/server-post-build-fn.ts +++ b/src/functions/server/server-post-build-fn.ts @@ -15,6 +15,48 @@ type Params = { changed_pages?: string[]; }; +/** + * # Push a client-only HMR update + * Sends the new client bundle to every controller whose page is in + * `changed_pages`, without re-running server functions. The payload carries + * no `page_props`, so the browser keeps its current props and only swaps the + * hydration script/CSS. Used right after the hydration build so the browser + * doesn't wait for the SSR rebuild. + */ +export function pushClientUpdate(changed_pages: string[]) { + if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) { + return; + } + + const changed_set = new Set(changed_pages); + + for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) { + const controller = global.BUNEXT_HMR_CONTROLLERS[i]; + const local_path = controller?.target_map?.local_path; + + if (!controller || !local_path || !changed_set.has(local_path)) { + continue; + } + + const target_artifact = global.BUNEXT_BUNDLER_CTX_MAP[local_path]; + + const payload = target_artifact?.path + ? { target_map: target_artifact } + : { reload: true }; + + try { + controller.controller.enqueue( + `event: update\ndata: ${JSON.stringify(payload)}\n\n`, + ); + if (target_artifact?.path) { + controller.target_map = target_artifact; + } + } catch { + global.BUNEXT_HMR_CONTROLLERS.splice(i, 1); + } + } +} + export default async function serverPostBuildFn(params?: Params) { if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) { return; diff --git a/src/functions/server/watcher-esbuild-ctx.ts b/src/functions/server/watcher-esbuild-ctx.ts index 78daed6..c4954dc 100644 --- a/src/functions/server/watcher-esbuild-ctx.ts +++ b/src/functions/server/watcher-esbuild-ctx.ts @@ -96,7 +96,7 @@ const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/; // 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; +const REBUILD_DEBOUNCE_MS = 50; let pending_structural = false; let pending_content_file: string | undefined = undefined; let debounce_timer: ReturnType | undefined = undefined; diff --git a/src/functions/server/web-pages/handle-web-pages.tsx b/src/functions/server/web-pages/handle-web-pages.tsx index 366d4aa..fa7b99a 100644 --- a/src/functions/server/web-pages/handle-web-pages.tsx +++ b/src/functions/server/web-pages/handle-web-pages.tsx @@ -5,6 +5,7 @@ import getCache from "../../cache/get-cache"; import generateWebPageResponseFromComponentReturn from "./generate-web-page-response-from-component-return"; import grabPageComponent from "./grab-page-component"; import grabPageErrorComponent from "./grab-page-error-component"; +import { awaitSSRRebuild } from "../../bundler/pages-ssr-context-bundler"; type Params = { req: Request; @@ -43,6 +44,12 @@ export default async function handleWebPages({ } catch (error) {} } + // HMR pushes client updates before the SSR rebuild finishes; a page + // load in that window waits so SSR HTML matches the new bundle. + if (is_dev) { + await awaitSSRRebuild(); + } + const componentRes = await grabPageComponent({ req, }); diff --git a/src/functions/server/web-pages/tailwind-esbuild-plugin.tsx b/src/functions/server/web-pages/tailwind-esbuild-plugin.tsx index 6983528..c7b2783 100644 --- a/src/functions/server/web-pages/tailwind-esbuild-plugin.tsx +++ b/src/functions/server/web-pages/tailwind-esbuild-plugin.tsx @@ -11,53 +11,81 @@ const { ROOT_DIR } = grabDirNames(); let error_logged = false; -const tailwindEsbuildPlugin: esbuild.Plugin = { - name: "tailwindcss", - setup(build) { - build.onLoad({ filter: /\.css$/ }, async (args) => { - try { - const source = await readFile(args.path, "utf-8"); - const result = await postcss([tailwindcss()]).process(source, { - from: args.path, - }); +// One processor for the whole process, so the Tailwind plugin's internal +// state survives across rebuilds instead of being recreated per file load. +let css_processor: postcss.Processor | undefined; - error_logged = false; +function grabCSSProcessor() { + if (!css_processor) { + css_processor = postcss([tailwindcss()]); + } + return css_processor; +} - return { contents: result.css, loader: "css" }; - } catch (error: any) { - return { errors: [{ text: error.message }] }; - } - }); - - build.onResolve({ filter: /\.css$/ }, async (args) => { - const css_path = path.resolve( - args.resolveDir, - args.path.replace(/\@\//g, ROOT_DIR + "/"), - ); - - const does_path_exist = existsSync(css_path); - - if (!does_path_exist && !error_logged) { - const err_msg = `CSS Error: ${css_path} file does not exist.`; - - log.error(err_msg); - error_logged = true; - - // return { - // errors: [ - // { - // text: err_msg, - // }, - // ], - // pluginName: "tailwindcss", - // }; - } - - return { - path: css_path, - }; - }); - }, +type Params = { + /** + * Run `.css` files through Tailwind/PostCSS. When `false`, every `.css` + * import loads as an empty stylesheet (for builds whose CSS output is + * never used, e.g. SSR). + */ + process_css?: boolean; }; +export function createTailwindEsbuildPlugin({ + process_css = true, +}: Params = {}): esbuild.Plugin { + return { + name: process_css ? "tailwindcss" : "css-stub", + setup(build) { + build.onLoad({ filter: /\.css$/ }, async (args) => { + if (!process_css) { + return { contents: "", loader: "css" }; + } + + try { + const source = await readFile(args.path, "utf-8"); + const result = await grabCSSProcessor().process(source, { + from: args.path, + }); + + error_logged = false; + + return { contents: result.css, loader: "css" }; + } catch (error: any) { + return { errors: [{ text: error.message }] }; + } + }); + + build.onResolve({ filter: /\.css$/ }, async (args) => { + const css_path = path.resolve( + args.resolveDir, + args.path.replace(/\@\//g, ROOT_DIR + "/"), + ); + + const does_path_exist = existsSync(css_path); + + if (!does_path_exist && !error_logged) { + const err_msg = `CSS Error: ${css_path} file does not exist.`; + + log.error(err_msg); + error_logged = true; + } + + return { + path: css_path, + }; + }); + }, + }; +} + +/** + * Resolves `.css` imports like the Tailwind plugin but loads them empty. + */ +export const cssStubEsbuildPlugin = createTailwindEsbuildPlugin({ + process_css: false, +}); + +const tailwindEsbuildPlugin = createTailwindEsbuildPlugin(); + export default tailwindEsbuildPlugin;