From a184f1a3becf3ca2aa6e9991ecfd7252d1db71a3 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 15:28:10 +0530 Subject: [PATCH] feat(islands): serve the island runtime in dev, prod, and static builds Adds /__wrnexus/islands.js (the bootstrap) and the /__wrnexus/island/ prefix (mount runtime, island bundles, shared chunks) to all three serving paths. Dev reuses the browserArtifactPaths registry pattern from pipeline.ts. Prod mirrors the clientModulesDir handler, including its filename allowlist, so island names cannot escape the output directory. The bootstrap is inert without a data-wrn-island marker, so island-free pages still download nothing. Co-Authored-By: Claude Opus 5 --- bun.lock | 1 + packages/cli/src/build.ts | 12 +++++++ packages/dev-server/src/assets.ts | 7 +++- packages/dev-server/src/pipeline.ts | 20 +++++++++++ packages/dev-server/src/prod.ts | 11 ++++++ packages/react/src/error-boundary.tsx | 3 +- packages/react/src/runtime-source.ts | 40 ++++++++++++++++++++++ packages/react/test/island-runtime.test.ts | 26 ++++++++------ packages/react/test/runtime-source.test.ts | 25 ++++++++++++++ tsconfig.json | 2 ++ 10 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 packages/react/src/runtime-source.ts create mode 100644 packages/react/test/runtime-source.test.ts diff --git a/bun.lock b/bun.lock index c05d184d..a7de97d9 100644 --- a/bun.lock +++ b/bun.lock @@ -301,6 +301,7 @@ "name": "@wrnexus/compiler", "version": "0.8.10", "dependencies": { + "@wrnexus/core": "workspace:*", "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", "@wrnexus/syntax": "workspace:*", diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 60047eac..ba00ff6b 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -24,6 +24,7 @@ import { import { basename, dirname, extname, join, relative, resolve } from "node:path"; import { buildRouter, type Route } from "@wrnexus/router"; import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr"; +import { getIslandRuntime } from "@wrnexus/react/runtime"; import { analyzeRuntimeImports, analyzeRuntimeRequirements, @@ -112,6 +113,7 @@ export async function runBuild(appRoot: string): Promise { const clientModulesDir = join(distDir, "client"); const reactivePath = join(distDir, "reactive.js"); const controllersPath = join(distDir, "controllers.js"); + const islandsPath = join(distDir, "islands.js"); const publicDir = join(root, "public"); const distPublicDir = join(distDir, "public"); const config = await loadAppConfig(root); @@ -493,6 +495,16 @@ export async function runBuild(appRoot: string): Promise { assetHash.update(controllerCode); console.log(`✓ Controllers: ${controllersPath}`); + // Island bootstrap: emitted unconditionally but inert without markers, so a + // build with no islands still ships no React. + const islandCode = await buildBrowserRuntime( + getIslandRuntime(), + islandsPath, + join(compiledDir, "islands.entry.js"), + ); + assetHash.update(islandCode); + console.log(`✓ Islands: ${islandsPath}`); + // 1a) Theme tokens + client switcher (always emitted; built-in light/dark). const theme = resolveThemeConfig(config.theme, config.cookies); const themeCss = renderThemeCss(theme); diff --git a/packages/dev-server/src/assets.ts b/packages/dev-server/src/assets.ts index e8dfd085..f0d94f85 100644 --- a/packages/dev-server/src/assets.ts +++ b/packages/dev-server/src/assets.ts @@ -25,6 +25,7 @@ import { type ResolvedTheme, type StylesConfig, } from "@wrnexus/styles"; +import { getIslandRuntime } from "@wrnexus/react/runtime"; import { VALIDATE_RUNTIME } from "@wrnexus/validation"; import { I18N_RUNTIME } from "@wrnexus/i18n"; import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from "@wrnexus/uploader"; @@ -32,7 +33,7 @@ import type { Mode } from "@wrnexus/core"; import type { AssetServer } from "./runtime.ts"; import { servePublicAsset } from "./public.ts"; import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts"; -import { serveWrnBrowserArtifact } from "./pipeline.ts"; +import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts"; /** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */ export interface DevStyles { @@ -95,6 +96,10 @@ export function createDevAssetServer( if (pathname.startsWith("/__wrnexus/client/")) { return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 }); } + if (pathname.startsWith("/__wrnexus/island/")) { + return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 }); + } + if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true)); if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true)); if (pathname === "/__wrnexus/controllers.js") return jsResponse(getComponentControllerRuntime(true)); diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index 795488ef..466a20ea 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -57,6 +57,7 @@ export function runMiddleware( const moduleCache = new Map>>(); const moduleVersions = new Map(); const browserArtifactPaths = new Map(); +const islandArtifactPaths = new Map(); type ImportMode = "legacy" | "compatible" | "explicit"; interface CompileImportOptions { @@ -644,6 +645,25 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null { }); } +/** Registers a built island asset for serving under `/__wrnexus/island/`. */ +export function registerIslandArtifact(pathname: string, artifact: string): void { + islandArtifactPaths.set(pathname, artifact); +} + +/** Serves a built island bundle, chunk, or the island mount runtime. */ +export function serveIslandArtifact(pathname: string): Response | null { + const artifact = islandArtifactPaths.get(pathname); + if (!artifact || !existsSync(artifact)) return null; + return new Response(readFileSync(artifact, "utf8"), { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store, max-age=0", + pragma: "no-cache", + expires: "0", + }, + }); +} + /** Forget one module and force its next dynamic import to bypass Bun's import cache. */ export function invalidateModule(file: string): void { file = resolve(file); diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index d41d4d29..d083840d 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -25,6 +25,7 @@ import { getNavRuntime, getRealtimeRuntime, } from "@wrnexus/csr"; +import { getIslandRuntime } from "@wrnexus/react/runtime"; import { loadEnv, resolveProfile, @@ -99,6 +100,7 @@ export interface ProdOptions { controllersPath?: string; /** Absolute directory containing bundled per-WRN browser modules. */ clientModulesDir?: string; + islandsDir?: string; /** Absolute path to the pre-built theme stylesheet (`theme.css`). */ themePath?: string; /** Pre-built active theme/accent stylesheets, loaded on demand. */ @@ -341,6 +343,15 @@ function createProdAssetServer(opts: ProdOptions): AssetServer { } return serveFile(join(opts.clientModulesDir, name), JS_HEADERS); } + if (pathname.startsWith("/__wrnexus/island/")) { + const name = pathname.slice("/__wrnexus/island/".length); + if (!opts.islandsDir || !/^[A-Za-z0-9._-]+\.js$/.test(name)) { + return new Response("Not Found", { status: 404 }); + } + return serveFile(join(opts.islandsDir, name), JS_HEADERS); + } + if (pathname === "/__wrnexus/islands.js") + return new Response(getIslandRuntime(), { headers: JS_HEADERS }); if (pathname === "/__wrnexus/reactive.js") { if (opts.reactivePath) { const file = Bun.file(opts.reactivePath); diff --git a/packages/react/src/error-boundary.tsx b/packages/react/src/error-boundary.tsx index 54224e0d..032e6692 100644 --- a/packages/react/src/error-boundary.tsx +++ b/packages/react/src/error-boundary.tsx @@ -4,7 +4,8 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; export interface IslandErrorBoundaryProps { name: string; development: boolean; - children: ReactNode; + /** Optional so createElement(Boundary, props, child) typechecks. */ + children?: ReactNode; } interface IslandErrorBoundaryState { diff --git a/packages/react/src/runtime-source.ts b/packages/react/src/runtime-source.ts new file mode 100644 index 00000000..ab9a50b9 --- /dev/null +++ b/packages/react/src/runtime-source.ts @@ -0,0 +1,40 @@ +/** + * The island bootstrap served at `/__wrnexus/islands.js`. + * + * Mirrors the `@wrnexus/csr` pattern: this file is only ever fetched when a + * `data-wrn-island` marker is present, so island-free pages download nothing — + * including React. + */ +export function getIslandRuntime(development = false): string { + return ` +(function () { + function loader(name) { + return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js"); + } + + function boot() { + if (!document.querySelector("[data-wrn-island]")) return; + import("/__wrnexus/island/runtime.js").then(function (runtime) { + runtime.mountIslands(document, { loader: loader, development: ${development} }); + window.__wrnexusUnmountIslands = function (root) { + runtime.unmountIslands(root || document); + }; + window.__wrnexusRemountIslands = function (root) { + return runtime.remountIslands(root || document, { + loader: loader, + development: ${development} + }); + }; + }).catch(function (error) { + console.error("[wrnexus] failed to load the island runtime", error); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); +`; +} diff --git a/packages/react/test/island-runtime.test.ts b/packages/react/test/island-runtime.test.ts index 800c83a1..0d01a7ef 100644 --- a/packages/react/test/island-runtime.test.ts +++ b/packages/react/test/island-runtime.test.ts @@ -5,6 +5,12 @@ import { islandRootCount, mountIslands, unmountIslands } from "../src/island-run (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +// happy-dom's element types do not structurally match lib.dom's ParentNode; +// this cast is a test-environment concern, not a runtime one. +function host(window: Window): ParentNode { + return window.document.body as unknown as ParentNode; +} + function domWith(html: string) { const window = new Window(); window.document.body.innerHTML = html; @@ -36,7 +42,7 @@ test("mounts an island and passes deserialized props", async () => { const window = domWith(marker('{"title":"Revenue"}')); await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); expect(window.document.body.textContent).toContain("Revenue"); @@ -47,12 +53,12 @@ test("unmounts roots and leaves no leaked roots behind", async () => { const window = domWith(marker('{"title":"A"}')); await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); expect(islandRootCount()).toBe(1); act(() => { - unmountIslands(window.document.body); + unmountIslands(host(window)); }); expect(islandRootCount()).toBe(0); }); @@ -62,10 +68,10 @@ test("repeated mount/unmount cycles do not accumulate roots", async () => { for (let i = 0; i < 5; i += 1) { await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); act(() => { - unmountIslands(window.document.body); + unmountIslands(host(window)); }); } @@ -75,7 +81,7 @@ test("repeated mount/unmount cycles do not accumulate roots", async () => { test("does nothing when no island markers are present", async () => { const window = domWith(`

plain server html

`); await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); expect(islandRootCount()).toBe(0); }); @@ -84,8 +90,8 @@ test("mounting twice does not create a second root for the same element", async const window = domWith(marker()); await act(async () => { - await mountIslands(window.document.body, { loader }); - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); + await mountIslands(host(window), { loader }); }); expect(islandRootCount()).toBe(1); @@ -97,7 +103,7 @@ test("a failing bundle load leaves the placeholder and mounts no root", async () console.error = () => {}; await act(async () => { - await mountIslands(window.document.body, { + await mountIslands(host(window), { loader: async () => { throw new Error("network down"); }, @@ -115,7 +121,7 @@ test("malformed props JSON falls back to empty props instead of throwing", async console.error = () => {}; await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); console.error = original; diff --git a/packages/react/test/runtime-source.test.ts b/packages/react/test/runtime-source.test.ts new file mode 100644 index 00000000..a34a5030 --- /dev/null +++ b/packages/react/test/runtime-source.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { getIslandRuntime } from "../src/runtime-source.ts"; + +test("emits a runtime that bails out when no island markers exist", () => { + const source = getIslandRuntime(false); + expect(source).toContain("data-wrn-island"); + expect(source).toContain("/__wrnexus/island/"); +}); + +test("registers a navigation hook so islands unmount on route change", () => { + expect(getIslandRuntime(false)).toContain("__wrnexusUnmountIslands"); +}); + +test("never references react-dom/server", () => { + expect(getIslandRuntime(true)).not.toContain("react-dom/server"); +}); + +test("threads the development flag into the mount options", () => { + expect(getIslandRuntime(true)).toContain("development: true"); + expect(getIslandRuntime(false)).toContain("development: false"); +}); + +test("encodes the island name before using it as a URL path segment", () => { + expect(getIslandRuntime(false)).toContain("encodeURIComponent"); +}); diff --git a/tsconfig.json b/tsconfig.json index bd5dda0a..dc398dc5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,8 @@ "@wrnexus/core": ["./packages/core/src/index.ts"], "@wrnexus/core/jsx-runtime": ["./packages/core/src/jsx-runtime.ts"], "@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"], + "@wrnexus/react": ["./packages/react/src/index.ts"], + "@wrnexus/react/runtime": ["./packages/react/src/runtime-source.ts"], "@wrnexus/reactive": ["./packages/reactive/src/index.ts"], "@wrnexus/graphql": ["./packages/graphql/src/index.ts"], "@wrnexus/graphql/*": ["./packages/graphql/src/*.ts"],