// The Vite plugin, `nib()`. It makes the consumer's pages cross-origin isolated, serves // (dev, preview) or emits (build) the runtime's files at a versioned path and its Service // Worker at the root of `base`, and wires the bare `@nibling/kernel` import to a module // that configures the host with those URLs, so the consumer's code only calls // getKernel(). // // `import app from "./dir?nib"` snapshots an app directory and exports a NibApp // describing it, never its contents. In dev the `.snap` is served lazily from // @nib/snap/; edits under the directory reach the page's running guest over HMR // (nib:fs, applied with runtime.applyFiles()) instead of reloading the page, and a // package.json, lockfile or node_modules change remounts the whole snapshot (nib:remount, // a replacing mount, so files deleted from the directory leave the guest's tree too). // A build emits it as a hashed asset. // // A build writes the layout below into outDir. The runtime's names are fixed, never // hashed: kernel.js finds runtime.js and swc.wasm as its siblings. // nib-sw.js the Service Worker (no-cache: its bytes change per release) // __nib//{kernel,runtime,sw}.js, swc.wasm immutable // assets/-.snap one per ?nib import // _headers Netlify and Cloudflare Pages read it // host.js is not among them: the wrapper module imports it, so it is bundled into the // consumer's chunks. The build's URLs are Rollup file URLs, relative to the chunk that // uses them, so they hold for a relative base ("./") as well as an absolute one. // // Runs under Node inside the consumer's Vite process. Only types come from vite. import type { Connect, Plugin, ResolvedConfig, ViteDevServer } from "vite"; import { createHash } from "node:crypto"; import { existsSync, readFileSync, statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { frameAncestors, nibHeaders } from "../node/server"; import type { ResourcePolicy } from "../shared/public-types.js"; import { encodeSnapshot, snapshot } from "../node/snapshot"; export interface NibOptions { /** URL pathname globs (base included) of the pages that boot the runtime: they get * COOP/COEP. `*` stays within a path segment, `**` crosses them. Default: every path. * COOP same-origin breaks OAuth and payment popups, so a site with those should * narrow this, e.g. `["/playground/**"]`. */ pages?: string[]; /** The static host a build writes header config for. "netlify" and "cloudflare" both * read `/_headers` (a `_headers` in publicDir is kept, with nib()'s rules * appended); "none" writes nothing and prints the headers to set (Vercel, nginx, ...). * Default: write `_headers`. */ host?: "netlify" | "cloudflare" | "none"; /** Cross-Origin-Resource-Policy for every path, and for what the Service Worker serves * (BootOptions.resourcePolicy). A sandbox origin that a coordinator on another site frames * needs "cross-origin". Default: "same-origin". */ resourcePolicy?: ResourcePolicy; /** Origins allowed to frame `pages`, sent as `Content-Security-Policy: frame-ancestors` * (an empty list forbids framing). Default: none, so any origin may frame them. */ embedders?: readonly string[]; /** Serve and emit the runtime. false sends `pages` their headers and nothing else, for a * coordinator that boots no kernel and frames sandboxes on other origins: a frame is * cross-origin isolated only inside an isolated page. Default: true. */ runtime?: boolean; } // The package root: two levels up from both this source (src/vite/index.ts) and its // shipped bundle (dist/node/vite.js), so the runtime is always /dist/runtime. const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url)); const RUNTIME_DIR = join(PACKAGE_ROOT, "dist/runtime"); const HOST_FILE = join(RUNTIME_DIR, "host.js").split("\\").join("/"); const VERSION = (JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as { version: string }).version; const HOST_ID = "\0@nibling/kernel"; /** ``: the module a `?nib` import resolves to. */ const APP_ID = "\0nib-app:"; const NIB_QUERY = /\?nib$/; const LOCKFILES = new Set(["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"]); // As scripts/fixtures.mjs picks an app's entry without package.json `main`. const ENTRY_CANDIDATES = ["server.mjs", "server.cjs", "server.js"]; /** What a build copies from dist/runtime into /__nib//. */ const RUNTIME_FILES = ["kernel.js", "runtime.js", "sw.js", "swc.wasm"]; const HEADERS_MARKER = "# @nibling/kernel: the runtime's headers, written by nib() (vite build)"; const MIME: Record = { ".js": "text/javascript; charset=utf-8", ".json": "application/json", ".wasm": "application/wasm", }; /** Where the runtime is served under `base` (a pathname ending in "/"). */ export function runtimeUrls(base: string): { dir: string; serviceWorker: string } { return { dir: `${base}__nib/${VERSION}/`, serviceWorker: `${base}nib-sw.js` }; } /** `configure()`'s fields beyond the runtime's URLs. */ const extraConfig = (resourcePolicy: ResourcePolicy | undefined): string => resourcePolicy ? `, resourcePolicy: ${JSON.stringify(resourcePolicy)}` : ""; /** The module the bare `@nibling/kernel` resolves to: the real host, configured once. */ function hostModule(urls: { dir: string; serviceWorker: string }, resourcePolicy: ResourcePolicy | undefined): string { const host = JSON.stringify(HOST_FILE); return [ `import { configure } from ${host};`, `configure({ base: ${JSON.stringify(urls.dir)}, serviceWorker: ${JSON.stringify(urls.serviceWorker)}${extraConfig(resourcePolicy)} });`, `export * from ${host};`, "", ].join("\n"); } /** The wrapper a build bundles: the same, with URLs Rollup resolves per chunk. */ function builtHostModule(kernelRef: string, serviceWorkerRef: string, resourcePolicy: ResourcePolicy | undefined): string { const host = JSON.stringify(HOST_FILE); return [ `import { configure } from ${host};`, `configure({ base: new URL(".", import.meta.ROLLUP_FILE_URL_${kernelRef}).href, serviceWorker: import.meta.ROLLUP_FILE_URL_${serviceWorkerRef}${extraConfig(resourcePolicy)} });`, `export * from ${host};`, "", ].join("\n"); } /** A pathname base for the dev server: Vite's `base` may be relative ("./") or a URL. */ function pathBase(base: string): string { let path = /^[a-z][a-z\d+.-]*:/i.test(base) ? new URL(base).pathname : base.startsWith("/") ? base : "/"; if (!path.endsWith("/")) path += "/"; return path; } function globMatcher(globs: string[]): (path: string) => boolean { const res = globs.map((glob) => new RegExp("^" + glob.replace(/\*\*\/|\*\*|\*|\?|[.+^${}()|[\]\\]/g, (t) => t === "**/" ? "(?:.*/)?" : t === "**" ? ".*" : t === "*" ? "[^/]*" : t === "?" ? "[^/]" : "\\" + t) + "$")); return (path) => res.some((re) => re.test(path)); } /** A page glob as a `_headers` path, which Netlify and Cloudflare read alike: `*` there * matches anything, "/" included, and a rule may hold only one, so a whole-segment `*` * becomes a named placeholder (`:p1`), which matches one segment. */ function hostPattern(glob: string): string { let placeholders = 0; const pattern = glob .split("/") .map((segment) => (segment === "*" ? `:p${++placeholders}` : segment.replaceAll("**", "*"))) .join("/") // `**/` crosses segments, as the splat does: `/a/**/*.html` is `/a/*.html`. .replaceAll("*/*", "*"); if (pattern.split("*").length > 2) throw new Error(`nib: a static host's _headers allows one wildcard per path, so pages can't hold "${glob}"`); return pattern; } /** A static host's header rules for a build: the same headers dev sends, as path * patterns (hostPattern). Both hosts join a header that two matching rules set, so each * header comes from one rule per path. */ function headerRules(base: string, options: NibOptions, isPage: (path: string) => boolean): { path: string; headers: Record }[] { const urls = runtimeUrls(base); const isolation = { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp" }; const page = { ...isolation, ...(options.embedders ? { "Content-Security-Policy": frameAncestors(options.embedders) } : {}) }; const pagePatterns = (options.pages ?? [`${base}**`]).map(hostPattern); const rules: { path: string; headers: Record }[] = [ { path: `${base}*`, headers: { "Cross-Origin-Resource-Policy": options.resourcePolicy ?? "same-origin" } }, ]; for (const path of pagePatterns) { const same = rules.find((rule) => rule.path === path); if (same) Object.assign(same.headers, page); else rules.push({ path, headers: { ...page } }); } if (options.runtime === false) return rules; // The runtime's own files are always isolated (see configureServer). rules.push({ path: `${base}__nib/*`, headers: { ...(isPage(`${urls.dir}kernel.js`) ? {} : isolation), "Cache-Control": "public, max-age=31536000, immutable" }, }); rules.push({ path: urls.serviceWorker, headers: { ...(isPage(urls.serviceWorker) ? {} : isolation), "Cache-Control": "no-cache", ...(base === "/" ? {} : { "Service-Worker-Allowed": "/" }), }, }); return rules; } function formatRules(rules: { path: string; headers: Record }[], indent = ""): string { return rules.map(({ path, headers }) => [indent + path, ...Object.entries(headers).map(([name, value]) => `${indent} ${name}: ${value}`)].join("\n")).join("\n") + "\n"; } function pathnameOf(url: string | undefined): string | null { try { return decodeURIComponent(new URL(url ?? "/", "http://localhost").pathname); } catch { return null; } } /** Serve `` from dist/runtime and the Service Worker from dist/runtime/sw.js. */ function runtimeMiddleware(urls: { dir: string; serviceWorker: string }, scope: string): Connect.NextHandleFunction { return (req, res, next) => { const path = pathnameOf(req.url); if (path === null) return next(); let file: string; if (path === urls.serviceWorker) file = "sw.js"; else if (path.startsWith(urls.dir)) file = path.slice(urls.dir.length); // Another version's directory: a 404, not the SPA fallback's index.html. else if (path.startsWith(scope)) file = ""; else return next(); const notFound = (): void => { res.statusCode = 404; res.end("not found"); }; if (!/^[\w-][\w.-]*$/.test(file)) return notFound(); readFile(join(RUNTIME_DIR, file)).then((data) => { res.setHeader("Content-Type", MIME[extname(file)] ?? "application/octet-stream"); // The in-repo dist/runtime is rebuilt under the same version, so dev revalidates. res.setHeader("Cache-Control", "no-cache"); res.end(data); }, notFound); }; } const toPosix = (path: string): string => path.split(sep).join("/"); /** A directory a `?nib` import named, and its memoized snapshot. */ interface AppDir { dir: string; /** The guest mount: "/" + `dir` relative to the Vite root. */ root: string; /** Names its `.snap` URL. */ id: string; snap: { encoded: Uint8Array; files: number } | null; } interface AppPackage { main?: string; nib?: { kind?: "server" | "cli" }; } function snapOf(app: AppDir): { encoded: Uint8Array; files: number } { if (!app.snap) { const snap = snapshot(app.dir, app.root); app.snap = { encoded: encodeSnapshot(snap), files: snap.files.length }; } return app.snap; } /** UTF-8 text as a string, anything else as base64, for the nib:fs payload. */ function fileDelta(file: string): string | { base64: string } | null { let bytes: Buffer; try { bytes = readFileSync(file); } catch { return null; // gone again before we read it: the unlink event follows } try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return { base64: bytes.toString("base64") }; } } /** The `?nib` module: metadata only, plus the HMR client that applies edits. It touches * the page's runtime only if one exists (getKernel() would boot one) and has this * app's root mounted. Vite drops a module instance's hot.on listeners when it re-runs * the module, so there is one pair per root. */ function appModule(app: Record): string { return `const app = ${JSON.stringify(app)}; export default app; ${appHmrClient}`; } /** The `?nib` module a build bundles: metadata and the emitted `.snap`'s URL. */ function builtAppModule(snapRef: string, app: Record): string { return `const app = { src: import.meta.ROLLUP_FILE_URL_${snapRef}, ...${JSON.stringify(app)} }; export default app; `; } const appHmrClient = `if (import.meta.hot) { import.meta.hot.accept(); const mounted = async () => { const entry = globalThis[Symbol.for("nib.runtime")]; if (!entry) return null; const runtime = await entry.runtime.catch(() => null); if (!runtime) return null; return (await runtime.fs.stat(app.root).then(() => true, () => false)) ? runtime : null; }; const bytes = (base64) => Uint8Array.fromBase64 ? Uint8Array.fromBase64(base64) : Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); import.meta.hot.on("nib:fs", async ({ root, files }) => { if (root !== app.root) return; const runtime = await mounted(); if (!runtime) return; await runtime.applyFiles(Object.fromEntries(Object.entries(files).map(([path, data]) => [path, data === null || typeof data === "string" ? data : bytes(data.base64)]))); }); import.meta.hot.on("nib:remount", async ({ root, src }) => { if (root !== app.root) return; const runtime = await mounted(); if (!runtime) return; const url = new URL(src, location.href); url.searchParams.set("t", String(Date.now())); await runtime.mount({ ...app, src: url.href }, { replace: true }); }); } `; export default function nib(options: NibOptions = {}): Plugin { let config: ResolvedConfig; let server: ViteDevServer | null = null; /** Rollup reference ids of the emitted runtime files, in a client build. */ let emitted: { kernel: string; serviceWorker: string } | null = null; /** Whether this is a client build, which writes header config. */ let building = false; const withRuntime = options.runtime !== false; const isPage = options.pages ? globMatcher(options.pages) : () => true; const apps = new Map(); // by dir const appsById = new Map(); const snapUrl = (app: AppDir): string => `${pathBase(config.base)}@nib/snap/${app.id}.snap`; const appFor = (file: string): AppDir | undefined => { for (const app of apps.values()) if (file.startsWith(app.dir + sep)) return app; return undefined; }; const appDir = (dir: string): AppDir => { let app = apps.get(dir); if (app) return app; const rel = relative(config.root, dir); const inside = rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); const name = basename(dir).replace(/[^\w.-]/g, "_"); app = { dir, root: "/" + (inside ? toPosix(rel) : name), id: `${name}-${createHash("sha256").update(dir).digest("hex").slice(0, 10)}`, snap: null, }; apps.set(dir, app); appsById.set(app.id, app); server?.watcher.add(dir); return app; }; /** A watched file under an app changed: invalidate its snapshot and tell the page. */ const onAppFile = (event: string, file: string): void => { const app = appFor(file); if (!app || !server || !(event === "add" || event === "change" || event === "unlink")) return; app.snap = null; const rel = toPosix(relative(app.dir, file)); if (rel === "package.json" || LOCKFILES.has(rel) || rel.startsWith("node_modules/")) { // entry and kind may have changed: the next page load gets fresh metadata. const mod = server.moduleGraph.getModuleById(APP_ID + app.dir); if (mod) server.moduleGraph.invalidateModule(mod); server.ws.send("nib:remount", { root: app.root, src: snapUrl(app) }); return; } server.ws.send("nib:fs", { root: app.root, files: { [`${app.root}/${rel}`]: event === "unlink" ? null : fileDelta(file) } }); }; // The runtime's files are always isolated: the kernel is a worker, and a worker script // under COEP require-corp needs its own COEP. const isolationHeaders = (base: string): Connect.NextHandleFunction => { const urls = runtimeUrls(base); const isRuntime = (path: string): boolean => withRuntime && (path.startsWith(urls.dir) || path === urls.serviceWorker); const embedders = options.embedders; return nibHeaders({ isolate: (path) => isRuntime(path) || isPage(path), resourcePolicy: options.resourcePolicy ?? "same-origin", csp: embedders ? (path) => (isPage(path) ? frameAncestors(embedders) : false) : false, // Scope "/" from below the origin root needs the header; at the root it doesn't. serviceWorkerAllowed: base === "/" || !withRuntime ? undefined : (path) => path === urls.serviceWorker, }) as Connect.NextHandleFunction; }; return { name: "nib", // Before vite:resolve, which would resolve an installed @nibling/kernel (and maybe a // `?nib`) itself, past the wrapper. enforce: "pre", config() { // One copy of the host, loaded as is: never prebundled or rewritten. return { optimizeDeps: { exclude: ["@nibling/kernel"] } }; }, configResolved(resolved) { config = resolved; // The host module is imported by its file path, which may sit outside the // workspace (a linked package, or this repo's fixture). const allow = resolved.server.fs.allow as string[]; if (!allow.includes(RUNTIME_DIR)) allow.push(RUNTIME_DIR); }, configureServer(devServer) { server = devServer; const base = pathBase(devServer.config.base); const urls = runtimeUrls(base); // Before Vite's own middlewares, so HTML, modules and runtime files all get them. devServer.middlewares.use(isolationHeaders(base)); if (withRuntime) devServer.middlewares.use(runtimeMiddleware(urls, `${base}__nib/`)); const snaps = `${base}@nib/snap/`; devServer.middlewares.use((req, res, next) => { const path = pathnameOf(req.url); if (path === null || !path.startsWith(snaps)) return next(); const app = appsById.get(path.slice(snaps.length).replace(/\.snap$/, "")); if (!app) { res.statusCode = 404; res.end("not found"); return; } let encoded: Uint8Array; try { encoded = snapOf(app).encoded; } catch (err) { return next(err); } res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Cache-Control", "no-cache"); res.end(encoded); }); devServer.watcher.on("all", onAppFile); }, // `vite preview` serves outDir as a build wrote it; it only lacks the headers, which // a static host would read from _headers. configurePreviewServer(preview) { const base = pathBase(preview.config.base); const urls = runtimeUrls(base); preview.middlewares.use(isolationHeaders(base)); // Another version's directory: a 404, not the SPA fallback's index.html. preview.middlewares.use((req, res, next) => { const path = pathnameOf(req.url); if (!withRuntime || path === null || !path.startsWith(`${base}__nib/`) || path.startsWith(urls.dir)) return next(); res.statusCode = 404; res.end("not found"); }); }, buildStart() { if (config.command !== "build" || config.build.ssr) return; building = true; if (!withRuntime) return; // `name` lists a file in Vite's manifest, keyed by `originalFileName` (its path in // the package; nib-sw.js shares sw.js's, so it goes by "_nib-sw.js"). `fileName` // keeps it unhashed. const emit = (fileName: string, file: string, key = true): string => this.emitFile({ type: "asset", fileName, name: file, originalFileName: key ? toPosix(relative(config.root, join(RUNTIME_DIR, file))) : null, source: readFileSync(join(RUNTIME_DIR, file)), }); const refs = new Map(RUNTIME_FILES.map((file) => [file, emit(`__nib/${VERSION}/${file}`, file)])); emitted = { kernel: refs.get("kernel.js")!, serviceWorker: emit("nib-sw.js", "sw.js", false) }; }, generateBundle() { if (!building || options.host === "none") return; const rules = formatRules(headerRules(pathBase(config.base), options, isPage)); // Vite copied publicDir into outDir before this; the merged file replaces its copy. const own = config.publicDir ? join(config.publicDir, "_headers") : ""; const existing = own && existsSync(own) ? readFileSync(own, "utf8") : ""; const source = (existing ? existing.replace(/\n*$/, "\n\n") : "") + `${HEADERS_MARKER}\n${rules}`; this.emitFile({ type: "asset", fileName: "_headers", source }); }, closeBundle() { if (!building || options.host !== "none") return; config.logger.info([ "", "nib: no host config was written (host: \"none\"). Serve these headers, e.g. from", "vercel.json, nginx or Caddy; `*` matches any path suffix:", formatRules(headerRules(pathBase(config.base), options, isPage), " "), ].join("\n")); }, resolveId(source, importer) { if (source === "@nibling/kernel" && importer !== HOST_ID) return HOST_ID; if (NIB_QUERY.test(source)) { const spec = source.replace(NIB_QUERY, ""); let dir: string; if (spec.startsWith(".")) dir = resolve(importer && !importer.startsWith("\0") ? dirname(importer.replace(/[?#].*$/, "")) : config.root, spec); // Vite's convention: "/x" is the root's x, else a file system path. else if (spec.startsWith("/")) dir = existsSync(join(config.root, spec)) ? join(config.root, spec) : spec; else return null; return APP_ID + resolve(dir); } return null; }, load(id) { if (id === HOST_ID) { return emitted ? builtHostModule(emitted.kernel, emitted.serviceWorker, options.resourcePolicy) : hostModule(runtimeUrls(pathBase(config.base)), options.resourcePolicy); } if (!id.startsWith(APP_ID)) return null; const dir = id.slice(APP_ID.length); const shown = toPosix(relative(config.root, dir)) || "."; if (!existsSync(dir) || !statSync(dir).isDirectory()) this.error(`${shown}?nib: not a directory`); const hasLockfile = [...LOCKFILES].some((name) => existsSync(join(dir, name))); if (hasLockfile && !existsSync(join(dir, "node_modules"))) { this.error(`${shown} has a lockfile but no node_modules: run "npm ci" in ${shown} (nib() never installs)`); } const pkgFile = join(dir, "package.json"); const pkg = (existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, "utf8")) : {}) as AppPackage; const app = appDir(dir); const snap = snapOf(app); const meta = { root: app.root, entry: pkg.main ?? ENTRY_CANDIDATES.find((file) => existsSync(join(dir, file))), kind: pkg.nib?.kind ?? "server", files: snap.files, bytes: snap.encoded.length, }; if (config.command === "build") { const ref = this.emitFile({ type: "asset", name: `${basename(dir)}.snap`, source: snap.encoded }); return builtAppModule(ref, meta); } return appModule({ src: snapUrl(app), ...meta }); }, // Edits under an app reach the page as nib:fs, never as a module update. handleHotUpdate({ file }) { return appFor(file) ? [] : undefined; }, }; }