// 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;
},
};
}