// The Vite plugin's dev server, from a consumer's side: tests/consumers/vite is a minimal // Vite app whose config is `plugins: [nib()]` and whose code only calls getKernel(). The // plugin must isolate the page, serve the runtime at /__nib// and the Service // Worker at /nib-sw.js, and configure the host with those URLs. With ?mode=nib the page // mounts examples/hello through a ?nib import, and edits on disk reach the running guest // over HMR without reloading the page. `vite build` emits the same layout into outDir, // plus _headers, and `vite preview` serves that build with the same outcomes. import { test, expect, type Page } from "@playwright/test"; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { build, createLogger, createServer, preview, type InlineConfig, type PreviewServer, type ViteDevServer } from "vite"; import nib from "../src/vite/index"; const FIXTURE = fileURLToPath(new URL("./consumers/vite/", import.meta.url)); const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version; const RUNTIME = `/__nib/${VERSION}/`; let server: ViteDevServer; let origin: string; function freePort(): Promise { return new Promise((resolve, reject) => { const probe = createNetServer().once("error", reject).listen(0, "localhost", () => { const { port } = probe.address() as { port: number }; probe.close(() => resolve(port)); }); }); } test.beforeAll(async () => { const port = await freePort(); server = await createServer({ configFile: `${FIXTURE}vite.config.ts`, logLevel: "warn", clearScreen: false, server: { port, strictPort: true, host: "localhost" }, }); await server.listen(); origin = `http://localhost:${port}`; }); test.afterAll(async () => { await server?.close(); }); /** Record the path of every request the page, its workers and its SW make. */ function recordPaths(page: Page): string[] { const paths: string[] = []; page.context().on("request", (req) => { paths.push(new URL(req.url()).pathname); }); return paths; } test("a nib() dev server isolates the page, serves the runtime and wires @nibling/kernel", async ({ page }) => { const paths = recordPaths(page); const res = await page.goto(`${origin}/`); expect(res?.headers()["cross-origin-embedder-policy"]).toBe("require-corp"); await expect(page.frameLocator("#app").locator("#msg")).toHaveText("hello from a Vite consumer", { timeout: 20_000 }); await expect(page.locator("#err")).toHaveText(""); expect(await page.evaluate(() => self.crossOriginIsolated)).toBe(true); expect(await page.evaluate(() => new URL(navigator.serviceWorker.controller!.scriptURL).pathname)).toBe("/nib-sw.js"); // swc.wasm loads only for TypeScript; the second test fetches it. for (const file of ["kernel.js", "runtime.js"]) expect(paths).toContain(RUNTIME + file); }); test("runtime files carry their content types and the isolation headers", async ({ request }) => { const wasm = await request.get(`${origin}${RUNTIME}swc.wasm`); expect(wasm.headers()["content-type"]).toBe("application/wasm"); const kernel = await request.get(`${origin}${RUNTIME}kernel.js`); expect(kernel.headers()["content-type"]).toMatch(/^text\/javascript/); expect(kernel.headers()["cross-origin-embedder-policy"]).toBe("require-corp"); expect(kernel.headers()["cross-origin-resource-policy"]).toBe("same-origin"); const sw = await request.get(`${origin}/nib-sw.js`); expect(sw.headers()["content-type"]).toMatch(/^text\/javascript/); expect(sw.headers()["service-worker-allowed"]).toBeUndefined(); expect((await request.get(`${origin}/__nib/0.0.0-other/kernel.js`)).status()).toBe(404); }); test("Vite's base prefixes every runtime URL, and pages narrows COOP/COEP", async ({ request }) => { const port = await freePort(); const sub = await createServer({ configFile: false, root: FIXTURE, cacheDir: fileURLToPath(new URL("../node_modules/.vite-consumer", import.meta.url)), logLevel: "warn", clearScreen: false, base: "/sub/", plugins: [nib({ pages: ["/sub/play/**"] })], server: { port, strictPort: true, host: "localhost" }, }); await sub.listen(); try { const at = `http://localhost:${port}`; expect((await request.get(`${at}/sub${RUNTIME}kernel.js`)).status()).toBe(200); // Below the origin root, scope "/" needs the header. expect((await request.get(`${at}/sub/nib-sw.js`)).headers()["service-worker-allowed"]).toBe("/"); const page = await request.get(`${at}/sub/`); expect(page.headers()["cross-origin-embedder-policy"]).toBeUndefined(); expect(page.headers()["cross-origin-resource-policy"]).toBe("same-origin"); expect((await request.get(`${at}/sub/play/`)).headers()["cross-origin-embedder-policy"]).toBe("require-corp"); const host = await (await request.get(`${at}/sub/@id/__x00__@nibling/kernel`)).text(); expect(host).toContain(`base: "/sub${RUNTIME}"`); expect(host).toContain(`serviceWorker: "/sub/nib-sw.js"`); } finally { await sub.close(); } }); test("resourcePolicy and embedders reach the headers and the Service Worker's guest responses", async ({ page, request }) => { const port = await freePort(); const sandbox = await createServer({ configFile: false, root: FIXTURE, cacheDir: fileURLToPath(new URL("../node_modules/.vite-consumer", import.meta.url)), logLevel: "warn", clearScreen: false, plugins: [nib({ resourcePolicy: "cross-origin", embedders: ["https://app.example"] })], server: { port, strictPort: true, host: "localhost" }, }); await sandbox.listen(); try { const at = `http://localhost:${port}`; const doc = await request.get(`${at}/`); expect(doc.headers()["cross-origin-resource-policy"], "the page's CORP").toBe("cross-origin"); expect(doc.headers()["content-security-policy"], "the page names its embedders").toBe("frame-ancestors https://app.example"); const kernel = await request.get(`${at}${RUNTIME}kernel.js`); expect(kernel.headers()["cross-origin-resource-policy"], "a runtime file's CORP").toBe("cross-origin"); const host = await (await request.get(`${at}/@id/__x00__@nibling/kernel`)).text(); expect(host).toContain(`resourcePolicy: "cross-origin"`); await page.goto(`${at}/`); await expect(page.frameLocator("#app").locator("#msg")).toHaveText("hello from a Vite consumer", { timeout: 20_000 }); expect(await page.evaluate(() => new URL(navigator.serviceWorker.controller!.scriptURL).search)).toBe("?resource-policy=cross-origin"); const guest = await page.evaluate(async () => { const res = await fetch(document.querySelector("#app")!.src); return [res.headers.get("cross-origin-resource-policy"), res.headers.get("cross-origin-embedder-policy")]; }); expect(guest, "the guest server's response, through the Service Worker").toEqual(["cross-origin", "require-corp"]); } finally { await sandbox.close(); } }); test("runtime: false sends pages their headers and serves no runtime", async ({ request }) => { const port = await freePort(); const coordinator = await createServer({ configFile: false, root: FIXTURE, cacheDir: fileURLToPath(new URL("../node_modules/.vite-consumer", import.meta.url)), logLevel: "warn", clearScreen: false, plugins: [nib({ pages: ["/edit"], runtime: false })], server: { port, strictPort: true, host: "localhost" }, }); await coordinator.listen(); try { const at = `http://localhost:${port}`; const edit = await request.get(`${at}/edit`); expect(edit.headers()["cross-origin-embedder-policy"]).toBe("require-corp"); expect(edit.headers()["cross-origin-opener-policy"]).toBe("same-origin"); expect((await request.get(`${at}/`)).headers()["cross-origin-embedder-policy"]).toBeUndefined(); const kernel = await request.get(`${at}${RUNTIME}kernel.js`); expect(kernel.headers()["content-type"], "no runtime file, only the SPA fallback").toMatch(/^text\/html/); expect(kernel.headers()["cross-origin-embedder-policy"]).toBeUndefined(); expect((await request.get(`${at}/nib-sw.js`)).headers()["content-type"]).toMatch(/^text\/html/); } finally { await coordinator.close(); } }); const HELLO = `${FIXTURE}examples/hello/`; /** Run `body`, then put each file back as it was. */ async function restoring(files: string[], body: () => Promise): Promise { const saved = files.map((file) => [file, readFileSync(file)] as const); try { await body(); } finally { for (const [file, bytes] of saved) writeFileSync(file, bytes); } } async function bootHello(page: Page): Promise { await page.goto(`${origin}/?mode=nib`); await expect(page.frameLocator("#app").locator("#msg")).toHaveText("hello from examples/hello", { timeout: 20_000 }); await expect(page.locator("#err")).toHaveText(""); await page.evaluate(() => { (window as unknown as { __marker: number }).__marker = 1; }); } const marker = (page: Page): Promise => page.evaluate(() => (window as unknown as { __marker?: number }).__marker); test("an edit under a ?nib dir reaches the running guest without a page reload", async ({ page }) => { await restoring([`${HELLO}message.txt`], async () => { await bootHello(page); const frame = page.frameLocator("#app"); writeFileSync(`${HELLO}message.txt`, "edited on disk\n"); await expect.poll(async () => { await page.locator("#app").evaluate((iframe: HTMLIFrameElement) => iframe.contentWindow!.location.reload()); return frame.locator("#msg").textContent({ timeout: 2_000 }).catch(() => null); }, { timeout: 10_000 }).toBe("edited on disk"); expect(await marker(page)).toBe(1); }); }); test("a package.json change remounts the snapshot, still without a page reload", async ({ page }) => { await restoring([`${HELLO}package.json`], async () => { await bootHello(page); const snapRequests: string[] = []; page.on("request", (req) => { if (new URL(req.url()).pathname.startsWith("/@nib/snap/")) snapRequests.push(req.url()); }); const pkg = JSON.parse(readFileSync(`${HELLO}package.json`, "utf8")) as Record; writeFileSync(`${HELLO}package.json`, JSON.stringify({ ...pkg, version: "1.0.1" }, null, 2) + "\n"); await expect.poll(() => snapRequests.some((url) => new URL(url).searchParams.has("t")), { timeout: 10_000 }).toBe(true); expect(await marker(page)).toBe(1); }); }); test("a ?nib module carries metadata only, and a lockfile without node_modules fails the import", async () => { const resolved = await server.pluginContainer.resolveId("./examples/hello?nib", `${FIXTURE}main.ts`); const loaded = await server.pluginContainer.load(resolved!.id); const code = typeof loaded === "string" ? loaded : loaded!.code!; const app = JSON.parse(/^const app = (.*);$/m.exec(code)![1]!) as Record; expect(app).toMatchObject({ root: "/examples/hello", entry: "server.cjs", kind: "server" }); expect(app.src).toMatch(/^\/@nib\/snap\/hello-[0-9a-f]+\.snap$/); expect(app.files).toBe(3); expect(code).not.toContain("hello from examples/hello"); const snap = await fetch(`${origin}${app.src as string}`); expect(snap.headers.get("cross-origin-resource-policy")).toBe("same-origin"); // The v3 format's magic and header. (Not app.bytes: the previous test's package.json // restore may land in the watcher between the load and this fetch.) const bytes = new Uint8Array(await snap.arrayBuffer()); expect(new TextDecoder().decode(bytes.subarray(0, 4))).toBe("NIBS"); const headerLength = new DataView(bytes.buffer).getUint32(4, true); const header = JSON.parse(new TextDecoder().decode(bytes.subarray(8, 8 + headerLength))) as { files: [string, number, number][] }; expect(header.files.map(([path]) => path)).toEqual(["/examples/hello/message.txt", "/examples/hello/package.json", "/examples/hello/server.cjs"]); const locked = mkdtempSync(join(tmpdir(), "nib-locked-")); try { writeFileSync(join(locked, "package.json"), "{}"); writeFileSync(join(locked, "package-lock.json"), "{}"); const id = (await server.pluginContainer.resolveId(`${locked}?nib`))!.id; await expect(server.pluginContainer.load(id)).rejects.toThrow(/has a lockfile but no node_modules: run "npm ci" in /); } finally { rmSync(locked, { recursive: true, force: true }); } }); test.describe("vite build and vite preview", () => { let outDir: string; const scratch: string[] = []; const temp = (prefix: string): string => { const dir = mkdtempSync(join(tmpdir(), prefix)); scratch.push(dir); return dir; }; /** Build the fixture (its own config unless `config` replaces it) into a temp outDir. */ async function buildFixture(config: InlineConfig = {}): Promise { const out = temp("nib-vite-build-"); await build({ configFile: `${FIXTURE}vite.config.ts`, logLevel: "warn", ...config, build: { outDir: out, emptyOutDir: true, ...config.build }, }); return out; } async function previewing(out: string, config: InlineConfig, body: (at: string) => Promise): Promise { const port = await freePort(); const server: PreviewServer = await preview({ configFile: `${FIXTURE}vite.config.ts`, logLevel: "warn", ...config, build: { outDir: out }, preview: { port, strictPort: true, host: "localhost" }, }); try { await body(`http://localhost:${port}`); } finally { await server.close(); } } test.beforeAll(async () => { outDir = await buildFixture(); }); test.afterAll(() => { for (const dir of scratch) rmSync(dir, { recursive: true, force: true }); }); test("a build emits the runtime unhashed, each snapshot hashed, nib-sw.js at the root and _headers", () => { for (const file of ["kernel.js", "runtime.js", "sw.js", "swc.wasm"]) expect(existsSync(join(outDir, `__nib/${VERSION}`, file))).toBe(true); // host.js is bundled into the consumer's chunks through the wrapper module. expect(existsSync(join(outDir, `__nib/${VERSION}/host.js`))).toBe(false); expect(readFileSync(join(outDir, "nib-sw.js"))).toEqual(readFileSync(new URL("../dist/runtime/sw.js", import.meta.url))); const snaps = readdirSync(join(outDir, "assets")).filter((name) => name.endsWith(".snap")); expect(snaps).toEqual([expect.stringMatching(/^hello-[\w-]{8}\.snap$/)]); expect(new TextDecoder().decode(readFileSync(join(outDir, "assets", snaps[0]!)).subarray(0, 4))).toBe("NIBS"); expect(readFileSync(join(outDir, "_headers"), "utf8")).toBe([ "# @nibling/kernel: the runtime's headers, written by nib() (vite build)", "/*", " Cross-Origin-Resource-Policy: same-origin", " Cross-Origin-Opener-Policy: same-origin", " Cross-Origin-Embedder-Policy: require-corp", "/__nib/*", " Cache-Control: public, max-age=31536000, immutable", "/nib-sw.js", " Cache-Control: no-cache", "", ].join("\n")); }); test("vite preview serves the build isolated, and both modes boot and run", async ({ page }) => { await previewing(outDir, {}, async (at) => { const res = await page.goto(`${at}/`); expect(res?.headers()["cross-origin-embedder-policy"]).toBe("require-corp"); await expect(page.frameLocator("#app").locator("#msg")).toHaveText("hello from a Vite consumer", { timeout: 20_000 }); await expect(page.locator("#err")).toHaveText(""); expect(await page.evaluate(() => self.crossOriginIsolated)).toBe(true); expect(await page.evaluate(() => new URL(navigator.serviceWorker.controller!.scriptURL).pathname)).toBe("/nib-sw.js"); expect((await page.request.get(`${at}${RUNTIME}kernel.js`)).headers()["cross-origin-embedder-policy"]).toBe("require-corp"); expect((await page.request.get(`${at}/__nib/0.0.0-other/kernel.js`)).status()).toBe(404); await page.goto(`${at}/?mode=nib`); await expect(page.frameLocator("#app").locator("#msg")).toHaveText("hello from examples/hello", { timeout: 20_000 }); await expect(page.locator("#err")).toHaveText(""); }); }); test("a relative base (\"./\") resolves every runtime and snapshot URL against its chunk", async ({ page }) => { const out = await buildFixture({ base: "./" }); const chunks = readdirSync(join(out, "assets")).filter((name) => name.endsWith(".js")).map((name) => readFileSync(join(out, "assets", name), "utf8")).join("\n"); expect(chunks).toContain(`"../__nib/${VERSION}/kernel.js",import.meta.url`); expect(chunks).toContain(`"../nib-sw.js",import.meta.url`); expect(chunks).not.toContain(`"/__nib/`); await previewing(out, { base: "./" }, async (at) => { await page.goto(`${at}/?mode=nib`); await expect(page.frameLocator("#app").locator("#msg")).toHaveText("hello from examples/hello", { timeout: 20_000 }); await expect(page.locator("#err")).toHaveText(""); }); }); test("_headers follows base and pages and keeps a publicDir _headers; host \"none\" prints them instead", async () => { const publicDir = temp("nib-vite-public-"); writeFileSync(join(publicDir, "_headers"), "/api/*\n X-Own: 1\n"); const sub: InlineConfig = { configFile: false, root: FIXTURE, base: "/sub/", publicDir }; const out = await buildFixture({ ...sub, plugins: [nib({ pages: ["/sub/play/**"] })] }); expect(readFileSync(join(out, "_headers"), "utf8")).toBe([ "/api/*", " X-Own: 1", "", "# @nibling/kernel: the runtime's headers, written by nib() (vite build)", "/sub/*", " Cross-Origin-Resource-Policy: same-origin", "/sub/play/*", " Cross-Origin-Opener-Policy: same-origin", " Cross-Origin-Embedder-Policy: require-corp", "/sub/__nib/*", " Cross-Origin-Opener-Policy: same-origin", " Cross-Origin-Embedder-Policy: require-corp", " Cache-Control: public, max-age=31536000, immutable", "/sub/nib-sw.js", " Cross-Origin-Opener-Policy: same-origin", " Cross-Origin-Embedder-Policy: require-corp", " Cache-Control: no-cache", " Service-Worker-Allowed: /", "", ].join("\n")); const logged: string[] = []; const logger = createLogger("info"); logger.info = (msg) => { logged.push(msg); }; const none = await buildFixture({ configFile: false, root: FIXTURE, publicDir: false, customLogger: logger, logLevel: "info", plugins: [nib({ host: "none" })] }); expect(existsSync(join(none, "_headers"))).toBe(false); const note = logged.find((msg) => msg.includes("nib: no host config was written")); expect(note).toContain(" /__nib/*\n Cache-Control: public, max-age=31536000, immutable\n"); expect(note).toContain(" /nib-sw.js\n Cache-Control: no-cache\n"); }); test("_headers carries resourcePolicy and embedders; runtime: false writes only the page rules", async () => { const sandbox = await buildFixture({ configFile: false, root: FIXTURE, publicDir: false, plugins: [nib({ resourcePolicy: "cross-origin", embedders: ["https://app.example", "http://127.0.0.1:5173"] })] }); expect(readFileSync(join(sandbox, "_headers"), "utf8")).toBe([ "# @nibling/kernel: the runtime's headers, written by nib() (vite build)", "/*", " Cross-Origin-Resource-Policy: cross-origin", " Cross-Origin-Opener-Policy: same-origin", " Cross-Origin-Embedder-Policy: require-corp", " Content-Security-Policy: frame-ancestors https://app.example http://127.0.0.1:5173", "/__nib/*", " Cache-Control: public, max-age=31536000, immutable", "/nib-sw.js", " Cache-Control: no-cache", "", ].join("\n")); const chunks = readdirSync(join(sandbox, "assets")).filter((name) => name.endsWith(".js")).map((name) => readFileSync(join(sandbox, "assets", name), "utf8")).join("\n"); expect(chunks).toContain(`resourcePolicy:"cross-origin"`); const coordinator = await buildFixture({ configFile: false, root: FIXTURE, publicDir: false, plugins: [nib({ pages: ["/new", "/*/*/edit"], runtime: false })] }); expect(existsSync(join(coordinator, "__nib")), "no runtime directory").toBe(false); expect(existsSync(join(coordinator, "nib-sw.js")), "no Service Worker").toBe(false); expect(readFileSync(join(coordinator, "_headers"), "utf8")).toBe([ "# @nibling/kernel: the runtime's headers, written by nib() (vite build)", "/*", " Cross-Origin-Resource-Policy: same-origin", "/new", " Cross-Origin-Opener-Policy: same-origin", " Cross-Origin-Embedder-Policy: require-corp", "/:p1/:p2/edit", " Cross-Origin-Opener-Policy: same-origin", " Cross-Origin-Embedder-Policy: require-corp", "", ].join("\n")); }); test("_headers names each whole-segment * in pages, since a host rule allows one wildcard", async () => { const out = await buildFixture({ configFile: false, root: FIXTURE, publicDir: false, plugins: [nib({ pages: ["/*/*/edit", "/repo/*/*/**"] })] }); const headers = readFileSync(join(out, "_headers"), "utf8"); expect(headers).toContain("\n/:p1/:p2/edit\n Cross-Origin-Opener-Policy: same-origin\n"); expect(headers).toContain("\n/repo/:p1/:p2/*\n Cross-Origin-Opener-Policy: same-origin\n"); await expect(buildFixture({ configFile: false, root: FIXTURE, publicDir: false, plugins: [nib({ pages: ["/a*/b*"] })] })) .rejects.toThrow('allows one wildcard per path, so pages can\'t hold "/a*/b*"'); }); });