SW proxy router changes

This commit is contained in:
Zef Hemel
2026-07-23 14:47:31 +02:00
parent 1f5a3de882
commit 7e0770d1f4
3 changed files with 285 additions and 9 deletions
@@ -0,0 +1,46 @@
import { expect, test } from "vitest";
import { belongsToAnotherSpace } from "./proxy_router.ts";
// A space bound at "/" registers its service worker at scope "/", so it
// receives requests for every *other* space on the origin too. Answering those
// from its own precache or local data is how `/notes/.client/auth.js` came
// back as the SPA shell — HTML where a JavaScript module was expected, leaving
// the login page blank.
//
// Paths here are already space-relative (basePathName stripped), so for the
// root worker they are the full path.
test.each([
"/notes/.client/auth.js",
"/notes/.client/client.js",
"/notes/.auth",
"/notes/.fs/index.md",
"/notes/.config",
"/deeply/nested/prefix/.client/app.css",
])("%s belongs to another space", (path) => {
expect(belongsToAnotherSpace(path)).toBe(true);
});
test.each([
// Our own surfaces sit directly under our base.
"/.client/client.js",
"/.auth",
"/.fs/index.md",
"/.config",
// Ordinary pages, including ones that merely look like a prefix.
"/",
"/index",
"/some/page",
"/notes/subpage",
// A page whose name starts with a dot but is not a known surface.
"/notes/.hidden",
// A dotted segment at the root that is not a server surface either.
"/.something-else",
])("%s is ours to handle", (path) => {
expect(belongsToAnotherSpace(path)).toBe(false);
});
test("a page named like a surface, one level down, is still another space", () => {
// `/x/.fs` is unambiguous: no space serves a page called ".fs".
expect(belongsToAnotherSpace("/x/.fs")).toBe(true);
});
+61 -9
View File
@@ -11,15 +11,51 @@ import {
import type { SyncEngine } from "./sync_engine.ts";
import { EventEmitter } from "../plugos/event.ts";
const alwaysProxy = [
"/.auth",
"/.shell",
"/.logout",
"/.config",
"/.logs",
"/.proxy",
"/.client/manifest.json",
// The server surfaces every space carries under its own base path. This worker
// can answer exactly two of them for its OWN space — `.client` from the
// install-time precache and `.fs` from locally synced files — and proxies the
// rest. Both lists below derive from this one, so a new surface is declared
// once instead of being kept in sync by hand.
const spaceSurfaces = [
"client",
"fs",
"auth",
"config",
"logout",
"shell",
"proxy",
"logs",
];
const locallyServed = ["client", "fs"];
// Always straight to the server: the space surfaces we have no local answer
// for, plus the server-wide ones, which only ever exist at the origin root.
// Matched as a prefix of the *space-relative* path.
const alwaysProxy = [
...spaceSurfaces
.filter((surface) => !locallyServed.includes(surface))
.map((surface) => `/.${surface}`),
"/.spaces",
"/.setup",
"/.instance",
];
const anotherSpaceSurface = new RegExp(
`/[^/]+/\\.(${spaceSurfaces.join("|")})(/|$)`,
);
/**
* Whether a space-relative path belongs to a *different* space on this origin.
*
* A space bound at "/" registers its worker at scope "/", so it is handed every
* sibling space's requests too. A space surface one or more levels down —
* "/notes/.client/auth.js" — is by definition not ours, since our own sit
* directly under our base. Answering those from our precache is how a sibling's
* login page came back as this space's app shell.
*/
export function belongsToAnotherSpace(pathname: string): boolean {
return anotherSpaceSurface.test(pathname);
}
export type ProxyRouterEvents = {
// Use case: the user likely has this file open in the editor, so it's good to prioritize syncing it
@@ -179,6 +215,12 @@ export class ProxyRouter extends EventEmitter<ProxyRouterEvents> {
return await fetch(request);
}
// Another space's surface, reachable only because this worker is
// scoped at the origin root. Never ours to answer.
if (belongsToAnotherSpace(pathname)) {
return await fetch(request);
}
// Not yet configured (no sync engine / local storage) — must proxy.
// No local data exists to fall back to.
if (!this.localSpacePrimitives || !this.syncEngine) {
@@ -227,7 +269,17 @@ export class ProxyRouter extends EventEmitter<ProxyRouterEvents> {
// Handle /.fs file system APIs
return this.handleRequest(pathname, request);
} else {
// Fallback to the app shell for all other requests (SPA)
// Fallback to the app shell for all other requests (SPA).
if (request.mode === "navigate" && this.online) {
try {
return await fetch(request);
} catch (e: any) {
if (e.message !== "Offline" && !isNetworkError(e)) {
throw e;
}
this.online = false;
}
}
return (
(await caches.match(this.precacheFiles["/"])) || fetch(request)
);
+178
View File
@@ -0,0 +1,178 @@
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, type Page, test } from "@playwright/test";
import {
ADMIN_PASSWORD,
ADMIN_USER,
getFreePort,
waitForServer,
} from "./fixtures";
/**
* A space bound at "/" registers its service worker at scope "/", which covers
* every path on the origin — including the prefixes of *other* spaces. These
* tests pin down that such a worker must not answer navigations belonging to a
* different space with its own cached app shell: that shell carries the root
* space's `<base href="/">`, so the client would boot resolving `.config` and
* `/.fs/*` against the server root and silently load the wrong space.
*
* Unlike the rest of the multi-space e2e suite, these run with the service
* worker ENABLED — the bug is unreachable without it.
*/
const BIN = "./target/debug/silverbullet";
const CWD = join(import.meta.dirname, "..");
let proc: ChildProcess;
let rootDir: string;
let base: string;
let cookie: string;
/** Call the admin API (nested at `/.spaces/api/admin`) as the logged-in admin. */
async function admin(method: string, path: string, body?: unknown) {
const resp = await fetch(`${base}/.spaces/api/admin/${path}`, {
method,
headers: { "Content-Type": "application/json", Cookie: cookie },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!resp.ok) {
throw new Error(`${method} ${path} -> ${resp.status} ${await resp.text()}`);
}
return resp.json();
}
/** Create a public space so the browser side needs no login. */
async function createSpace(name: string, prefix: string): Promise<string> {
const { id } = await admin("POST", "spaces", {
name,
folder: join(rootDir, `${name}-space`),
binding: { prefix },
public: true,
seedIndex: true,
});
return id;
}
/**
* Load the root space and wait until its service worker controls the page —
* that worker is what the assertions below guard. Each test gets a fresh
* browser context, so this re-registers per test.
*/
async function registerRootServiceWorker(page: Page) {
await page.goto(`${base}/`);
await page.waitForFunction(
() => !!navigator.serviceWorker.controller,
undefined,
{
timeout: 30_000,
},
);
// Let the worker finish precaching the app shell before we navigate away.
await page.waitForTimeout(2000);
}
test.beforeAll(async () => {
rootDir = await mkdtemp(join(tmpdir(), "sb-sw-scope-e2e-"));
execFileSync(
BIN,
["setup", rootDir, "--admin", `${ADMIN_USER}:${ADMIN_PASSWORD}`],
{
cwd: CWD,
stdio: "pipe",
},
);
const port = await getFreePort();
proc = spawn(BIN, [rootDir, "-p", String(port), "-L", "127.0.0.1"], {
cwd: CWD,
stdio: ["ignore", "pipe", "pipe"],
// Note: SB_DISABLE_SERVICE_WORKER is deliberately NOT set here.
env: { ...process.env, SB_RUNTIME_API: "0" },
});
base = `http://127.0.0.1:${port}`;
await waitForServer(`${base}/.spaces`);
const login = await fetch(`${base}/.spaces/api/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASSWORD }),
});
cookie = login.headers
.getSetCookie()
.map((c) => c.split(";")[0])
.join("; ");
// The root-bound space whose service worker shadows the whole origin. Both
// tests depend on it, and only one space can hold "/" — so it is created
// once here rather than per test.
await createSpace("root", "/");
});
test.afterAll(async () => {
proc?.kill();
await rm(rootDir, { recursive: true, force: true });
});
test("a sibling space is not shadowed by the root space's service worker", async ({
page,
}) => {
await registerRootServiceWorker(page);
await createSpace("sibling", "/sibling");
await page.goto(`${base}/sibling/`);
// The decisive assertion: the client must boot against the sibling space's
// base URL. When the root worker serves its own cached shell instead, this
// is `${base}/` and the client silently loads the root space.
expect(await page.evaluate(() => document.baseURI)).toBe(`${base}/sibling/`);
});
test("a space moved to a different prefix still boots", async ({ page }) => {
const id = await createSpace("moved", "/before");
await registerRootServiceWorker(page);
await admin("PATCH", `spaces/${id}`, { binding: { prefix: "/after" } });
await page.goto(`${base}/after/`);
expect(await page.evaluate(() => document.baseURI)).toBe(`${base}/after/`);
});
test("a sibling space's client assets are not answered with the app shell", async ({
page,
}) => {
// The root worker's scope covers `/private/.client/*`, but those files are
// not in its precache (whose keys are its own space's) and not in its local
// data — so it used to fall through to the SPA-shell fallback and answer a
// JavaScript module request with HTML. The browser refuses to execute that,
// and the login page it belongs to renders blank.
//
// A private space, because its login page is the one asset request a visitor
// makes before authenticating anywhere — the case a user actually hits.
await registerRootServiceWorker(page);
await admin("POST", "spaces", {
name: "private",
folder: join(rootDir, "private-space"),
binding: { prefix: "/private" },
public: false,
seedIndex: true,
});
const asset = await page.evaluate(async (url) => {
const response = await fetch(url);
return {
status: response.status,
body: (await response.text()).slice(0, 40),
};
}, `${base}/private/.client/auth.js`);
expect(asset.status).toBe(200);
expect(asset.body).not.toContain("<!doctype");
// And the page that needs it actually renders its form.
await page.goto(`${base}/private/`);
await expect(page.locator("#login")).toBeVisible({ timeout: 30_000 });
await expect(page.locator("#username")).toBeVisible();
});