From c11a1cc8f6d2251794ef3d37194571abc604911a Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:49:11 +0200 Subject: [PATCH 1/5] Single-flight the connect gate's per-isolate caches (plan 007 step 5) A cold isolate's page-load burst (100+ authenticated requests) paid one label-resolve and one session-verify D1 round trip per request until the first one settled, because labelCache/sessionCache stored only settled values. Store the in-flight promise at query start instead, so request 2..N of a burst join request 1's round trip; a rejected lookup is evicted on settle so a D1 hiccup cannot poison a key for its TTL. TTLs and the fresh-read bypass are unchanged (they encode revocation latency). New tests fail before (6 D1 selects for a 6-request burst) and pass after (1 select), for both resolveLabel and verifySessionCookie. Co-Authored-By: Claude Fable 5 --- apps/connect/src/session.test.ts | 113 +++++++++++++++++++++++++ apps/connect/src/session.ts | 139 +++++++++++++++++++++---------- 2 files changed, 206 insertions(+), 46 deletions(-) diff --git a/apps/connect/src/session.test.ts b/apps/connect/src/session.test.ts index 585fb66ac3..137ffbfd44 100644 --- a/apps/connect/src/session.test.ts +++ b/apps/connect/src/session.test.ts @@ -24,6 +24,7 @@ import { markMachineSeen, resolveLabel, verifyMachineCredentialDetails, + verifySessionCookie, verifySessionCookieDetails, } from "./session.js"; import { refreshAccountSessionCookies } from "./account-session.js"; @@ -547,6 +548,118 @@ describe("account session refresh", () => { }); }); +/** + * Counts D1 round trips without mocking the database: every drizzle query + * starts with `db.select(...)`, so counting reads of the `select` property + * counts queries. Methods are bound to the real db so drizzle internals never + * re-enter the proxy. + */ +function countingDb(target: typeof db): { + db: typeof db; + counts: { select: number }; +} { + const counts = { select: 0 }; + const proxied = new Proxy(target, { + get(t, prop) { + if (prop === "select") counts.select += 1; + const value = Reflect.get(t, prop); + return typeof value === "function" ? value.bind(t) : value; + }, + }); + return { db: proxied, counts }; +} + +describe("single-flight gate caches", () => { + it("collapses a cold burst of label lookups into one D1 round trip", async () => { + seedUser("acct-flight"); + seedServer({ + id: "srv-flight", + userId: "acct-flight", + name: "default", + subdomain: "flight-label", + }); + const counted = countingDb(db); + + const resolved = await Promise.all( + Array.from({ length: 6 }, () => resolveLabel("flight-label", counted.db)), + ); + + expect(counted.counts.select).toBe(1); + for (const label of resolved) { + expect(label).toMatchObject({ kind: "server", userId: "acct-flight" }); + } + }); + + it("does not cache a failed lookup: the next request retries D1", async () => { + seedUser("acct-flight-retry"); + seedServer({ + id: "srv-flight-retry", + userId: "acct-flight-retry", + name: "default", + subdomain: "flight-retry", + }); + let failNext = true; + const failingOnce = new Proxy(db, { + get(t, prop) { + if (prop === "select" && failNext) { + failNext = false; + throw new Error("d1 hiccup"); + } + const value = Reflect.get(t, prop); + return typeof value === "function" ? value.bind(t) : value; + }, + }); + + await expect(resolveLabel("flight-retry", failingOnce)).rejects.toThrow( + "d1 hiccup", + ); + await expect(resolveLabel("flight-retry", failingOnce)).resolves.toMatchObject( + { kind: "server", userId: "acct-flight-retry" }, + ); + }); + + it("collapses a cold burst of session verifications into one D1 round trip", async () => { + seedUser("acct-cookie-flight"); + const token = `sess_flight_${crypto.randomUUID()}`; + const secret = "flight-secret"; + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sigBuf = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(token), + ); + const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf))); + const cookieValue = `${token}.${sig}`; + db.insert(session) + .values({ + id: `sess-${token}`, + token, + // Must be relative to wall clock: verifySessionCookie uses Date.now(). + expiresAt: new Date(Date.now() + 60_000), + userId: "acct-cookie-flight", + createdAt: now, + updatedAt: now, + }) + .run(); + const counted = countingDb(db); + + const verified = await Promise.all( + Array.from({ length: 6 }, () => + verifySessionCookie(cookieValue, secret, counted.db), + ), + ); + + expect(counted.counts.select).toBe(1); + expect(verified).toEqual(Array.from({ length: 6 }, () => "acct-cookie-flight")); + }); +}); + describe("machine credential presence", () => { it("verifies the owning machine and throttles lastSeenAt writes", async () => { seedUser("acct-machine"); diff --git a/apps/connect/src/session.ts b/apps/connect/src/session.ts index c6d36dfd2a..0b9637bd19 100644 --- a/apps/connect/src/session.ts +++ b/apps/connect/src/session.ts @@ -18,6 +18,12 @@ import { // D1. TTLs are short so sign-out / disconnect take effect quickly (and the DO // already severs a live tunnel on revoke, so a stale-cached label still can't // reach a disconnected server). +// +// Entries hold the lookup's promise, stored the moment the query starts, so a +// COLD isolate's request burst is also one D1 round trip per key: request +// 2..N join request 1's in-flight lookup instead of issuing their own. A +// rejected lookup is evicted when it settles — only successful lookups (and +// deliberate cached negatives) live out the TTL. const LABEL_TTL_MS = 15_000; const SESSION_TTL_MS = 20_000; const SESSION_REFRESH_BEFORE_EXPIRY_MS = @@ -25,7 +31,7 @@ const SESSION_REFRESH_BEFORE_EXPIRY_MS = 1000; interface CacheEntry { - value: T; + value: Promise; expires: number; } const labelCache = new Map>(); @@ -45,13 +51,38 @@ function cacheGet( map: Map>, key: string, now: number, -): T | undefined { +): Promise | undefined { const hit = map.get(key); if (hit && hit.expires > now) return hit.value; if (hit) map.delete(key); return undefined; } +function cacheStore( + map: Map>, + key: string, + value: Promise, + expires: number, + /** Recomputes the entry's expiry once the lookup lands (e.g. clamping a + * session entry to its D1 row's own expiration). */ + settledExpires?: (value: T) => number, +): Promise { + const entry: CacheEntry = { value, expires }; + map.set(key, entry); + value.then( + (settled) => { + // Guard the identity: a fresh read or an invalidation may have + // replaced this entry already. + if (settledExpires === undefined || map.get(key) !== entry) return; + entry.expires = settledExpires(settled); + }, + () => { + if (map.get(key) === entry) map.delete(key); + }, + ); + return value; +} + interface ResolvedServer { kind: "server"; /** @@ -107,7 +138,18 @@ export async function resolveLabel( const cached = cacheGet(labelCache, label, now); if (cached !== undefined) return cached; } + return cacheStore( + labelCache, + label, + lookupLabel(label, db), + now + LABEL_TTL_MS, + ); +} +async function lookupLabel( + label: string, + db: ConnectDb, +): Promise { const serverRow = await db .select({ userId: server.userId, @@ -120,7 +162,7 @@ export async function resolveLabel( .where(eq(server.subdomain, label)) .get(); if (serverRow) { - const resolvedServer: ResolvedServer = { + return { kind: "server", userId: serverRow.userId, server: { @@ -130,11 +172,6 @@ export async function resolveLabel( lastSeenAt: serverRow.lastSeenAt, }, }; - labelCache.set(label, { - value: resolvedServer, - expires: now + LABEL_TTL_MS, - }); - return resolvedServer; } const machineRow = await db @@ -159,25 +196,19 @@ export async function resolveLabel( ) .where(eq(machine.subdomain, label)) .get(); - const resolvedMachine: ResolvedMachine | null = machineRow - ? { - kind: "machine", - routingKey: machineRoutingKey(label, machineRow.generation), - userId: machineRow.userId, - accountHandle: machineRow.accountHandle, - machine: { - id: machineRow.machineId, - credentialHash: machineRow.credentialHash, - revokedAt: machineRow.revokedAt, - lastSeenAt: machineRow.lastSeenAt, - }, - } - : null; - labelCache.set(label, { - value: resolvedMachine, - expires: now + LABEL_TTL_MS, - }); - return resolvedMachine; + if (!machineRow) return null; + return { + kind: "machine", + routingKey: machineRoutingKey(label, machineRow.generation), + userId: machineRow.userId, + accountHandle: machineRow.accountHandle, + machine: { + id: machineRow.machineId, + credentialHash: machineRow.credentialHash, + revokedAt: machineRow.revokedAt, + lastSeenAt: machineRow.lastSeenAt, + }, + }; } export interface VerifiedSessionCookie { @@ -214,8 +245,6 @@ export async function verifySessionCookieDetails( const decoded = safeDecode(cookieValue); const dot = decoded.lastIndexOf("."); if (dot <= 0) return null; - const token = decoded.slice(0, dot); - const providedSig = decoded.slice(dot + 1); const now = Date.now(); // Cache on the full `token.sig` value, not the token alone: keying on the @@ -224,9 +253,38 @@ export async function verifySessionCookieDetails( // one would negative-poison the real token). The full-cookie key makes the // cache reflect exactly what passed verification. const cached = cacheGet(sessionCache, decoded, now); - if (cached !== undefined) - return cached === null ? null : verifiedSession(cached, now); + const cachedSession = + cached !== undefined + ? await cached + : await cacheStore( + sessionCache, + decoded, + lookupCachedSession( + decoded.slice(0, dot), + decoded.slice(dot + 1), + secret, + db, + now, + ), + now + SESSION_TTL_MS, + // A positive entry must not outlive its D1 session row; the clamp + // runs at settle time because a single-flight entry is stored + // before the row is known. + (looked) => + looked === null + ? now + SESSION_TTL_MS + : Math.min(now + SESSION_TTL_MS, looked.expiresAt), + ); + return cachedSession === null ? null : verifiedSession(cachedSession, now); +} +async function lookupCachedSession( + token: string, + providedSig: string, + secret: string, + db: ConnectDb, + now: number, +): Promise { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), @@ -240,27 +298,16 @@ export async function verifySessionCookieDetails( new TextEncoder().encode(token), ); const expectedSig = btoa(String.fromCharCode(...new Uint8Array(sigBuf))); - if (!constantTimeEqual(providedSig, expectedSig)) { - sessionCache.set(decoded, { value: null, expires: now + SESSION_TTL_MS }); - return null; - } + // A bad signature is a deliberate cached negative: it costs no D1 read and + // keeps a forged cookie from hammering the crypto path per request. + if (!constantTimeEqual(providedSig, expectedSig)) return null; const row = await db .select({ expiresAt: session.expiresAt, userId: session.userId }) .from(session) .where(and(eq(session.token, token), gt(session.expiresAt, new Date(now)))) .get(); - const cachedSession = row - ? { userId: row.userId, expiresAt: row.expiresAt.getTime() } - : null; - sessionCache.set(decoded, { - value: cachedSession, - expires: - row === undefined - ? now + SESSION_TTL_MS - : Math.min(now + SESSION_TTL_MS, row.expiresAt.getTime()), - }); - return cachedSession === null ? null : verifiedSession(cachedSession, now); + return row ? { userId: row.userId, expiresAt: row.expiresAt.getTime() } : null; } /** Returns the owning user for callers that do not participate in refresh. */ From cb6c05e0bae00b256e1fb42dcb4729471a85ebc8 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:52:27 +0200 Subject: [PATCH 2/5] Front-load the stylesheet and font preload in the built document (plan 007 step 1) Vite appends its built asset tags as [entry script, 69 modulepreloads, stylesheet], and the font preload plugin appended after that, so the render-blocking stylesheet was the 68th resource the preload scanner discovered and the Inter preload was dead last. The connect tunnel serializes responses FIFO on one WebSocket, so discovery order is delivery order: first paint sat behind ~1.5 MB of JavaScript. The bb:font-preload post transform now performs the head surgery itself: it moves the stylesheet (with fetchpriority=high) and the font preload ahead of the entry script and modulepreload block, keeps the pre-paint theme script ahead of the stylesheet (build fails loudly if that ever inverts), and leaves the body palette script's append-last contract intact. The new emitted-order test asserts against the real dist/index.html: before this change it failed with stylesheet at byte 9654 vs first modulepreload at 3861; after, the order is theme script (2891) < font preload (3801) < stylesheet (3922) < entry (3985) < modulepreloads (4065). Co-Authored-By: Claude Fable 5 --- apps/app/src/vite-font-preload.test.ts | 94 +++++++++++++++++++++++++- apps/app/vite-font-preload.ts | 92 +++++++++++++++++++++++-- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/apps/app/src/vite-font-preload.test.ts b/apps/app/src/vite-font-preload.test.ts index 0d6f401274..899ab5ccef 100644 --- a/apps/app/src/vite-font-preload.test.ts +++ b/apps/app/src/vite-font-preload.test.ts @@ -1,5 +1,10 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveFontPreloadTags } from "../vite-font-preload.js"; +import { + reorderHeadForFirstPaint, + resolveFontPreloadTags, +} from "../vite-font-preload.js"; const bundle = [ "assets/index-rXrqkkAU.js", @@ -37,3 +42,90 @@ describe("resolveFontPreloadTags", () => { expect(resolveFontPreloadTags(["assets/index-abc.js"], "/")).toEqual([]); }); }); + +/** The head layout Vite emits: theme script, then entry + preloads + css. */ +const builtHtml = [ + "", + '', + '', + '', + '', + '', + "", +].join(""); + +describe("reorderHeadForFirstPaint", () => { + const fontTags = resolveFontPreloadTags(bundle, "/"); + + it("moves the stylesheet and font preload ahead of the script and preload block", () => { + const html = reorderHeadForFirstPaint(builtHtml, fontTags); + + const themeAt = html.indexOf("bb.theme"); + const fontAt = html.search(/]*as="font"/); + const stylesheetAt = html.search(/]*rel="stylesheet"/); + const entryAt = html.search(/"; + expect(reorderHeadForFirstPaint(bare, [])).toBe(bare); + }); + + it("refuses to move the stylesheet ahead of the pre-paint theme script", () => { + const themeless = builtHtml.replace("bb.theme", "bb.other"); + expect(() => reorderHeadForFirstPaint(themeless, fontTags)).toThrow( + /pre-paint theme script/, + ); + }); +}); + +const distIndexHtmlPath = resolve(import.meta.dirname, "../dist/index.html"); + +/** + * The built document, not a fixture: the tunnel serializes responses FIFO on + * one WebSocket, so discovery order in dist/index.html IS delivery order on + * the relayed mobile path. The render-blocking stylesheet and the font + * preload must be discovered before the modulepreload block, and the + * pre-paint theme script must still run before the stylesheet applies. + * Skipped when dist/ is absent (test runs without a build). + */ +describe.skipIf(!existsSync(distIndexHtmlPath))( + "emitted dist/index.html head order", + () => { + it("puts the stylesheet and font preload before every modulepreload, after the theme script", () => { + const html = readFileSync(distIndexHtmlPath, "utf8"); + const stylesheetAt = html.search(/]*rel="stylesheet"/); + const fontPreloadAt = html.search(/]*as="font"/); + const firstModulepreloadAt = html.search(/ value !== undefined && value !== false) + .map(([name, value]) => (value === true ? name : `${name}="${value}"`)) + .join(" "); + return `<${tag.tag} ${attrs}>`; +} + +/** + * Moves the render-blocking stylesheet and the font preload ahead of the + * modulepreload block in the built document. + * + * Vite appends its asset tags in [entry script, modulepreload…, stylesheet] + * order, which put the stylesheet 68th and the font preload last among the + * document's resources. The tunnel relay serializes responses FIFO on one + * WebSocket, so discovery order is delivery order: first paint waited for + * ~1.5 MB of JavaScript to clear the wire before the CSS arrived. + * + * The pre-paint theme script (`bb.theme` in index.html) must keep running + * before the stylesheet applies — otherwise every dark-mode cold load + * flashes the light palette — so this refuses to move the stylesheet ahead + * of it and fails the build rather than shipping the flash. + */ +export function reorderHeadForFirstPaint( + html: string, + fontPreloadTags: HtmlTagDescriptor[], +): string { + const stylesheets: string[] = []; + const withoutStylesheets = html.replace( + /[ \t]*]*rel="stylesheet"[^>]*>\n?/g, + (tag) => { + stylesheets.push(tag.trim()); + return ""; + }, + ); + + const block = [ + ...fontPreloadTags.map(serializeTag), + ...stylesheets.map((tag) => + tag.includes("fetchpriority") + ? tag + : tag.replace("]*src=/), + html.indexOf(""), + ].filter((index) => index >= 0); + if (candidates.length === 0) { + throw new Error("bb:font-preload: built index.html has no "); + } + return Math.min(...candidates); +} + /** - * Preloads the Inter latin woff2 from index.html. Without it the font request - * starts only once the CSS has parsed and the first text node needs it, which - * on a phone is after ~1.5 MB of JavaScript. Build-only: the dev server has no - * hashed asset to point at, and dev has no first-paint budget. + * Build-only head surgery for first paint: preloads the Inter latin woff2 and + * moves it plus the app stylesheet ahead of the modulepreload block (see + * reorderHeadForFirstPaint). Without the preload the font request starts only + * once the CSS has parsed and the first text node needs it, which on a phone + * is after ~1.5 MB of JavaScript. The dev server has no hashed asset to point + * at, and dev has no first-paint budget. */ export function fontPreload(): Plugin { let base = "/"; @@ -59,9 +134,12 @@ export function fontPreload(): Plugin { }, transformIndexHtml: { order: "post", - handler(_html, ctx) { - if (ctx.bundle === undefined) return []; - return resolveFontPreloadTags(Object.keys(ctx.bundle), base); + handler(html, ctx) { + if (ctx.bundle === undefined) return html; + return reorderHeadForFirstPaint( + html, + resolveFontPreloadTags(Object.keys(ctx.bundle), base), + ); }, }, }; From 0203c0b0429bf51a883647e65b36c4389fe8aba3 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:54:15 +0200 Subject: [PATCH 3/5] Merge boot micro-chunks with rolldown advancedChunks (plan 007 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolldown's automatic splitting left the boot payload as 70 chunks, 34 of them under 4 KB and 18 under the 1 KiB precompress floor — half the boot requests carried ~2% of the bytes, and on the relayed mobile path each one is a full worker → DO → tunnel → laptop round trip. Two advancedChunks groups tagged $initial (the entry's static-import closure) merge that graph: a vendor group so app-only releases keep the vendor hash cacheable, and an app group for the rest. Lazy-route and on-demand facades are untouched (their modules are not $initial), so the budget's closure walk, forbiddenPackages and onDemandPackages gates hold unchanged. Measured (bundle-stats.json + check-bundle-budget): boot chunks 70 -> 3 boot raw 1575.8 KB -> 1548.2 KB boot brotli 443.0 KB -> 381.2 KB SplitWorkspaceRoute closure 2018.4/538.6 KB -> 2001.1/533.5 KB (45 chunks) index.html 10.8 KB -> 5.2 KB (69 -> 2 modulepreloads) bundle budget OK Co-Authored-By: Claude Fable 5 --- apps/app/vite.config.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index a9b3515d1e..7c93173792 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -33,6 +33,37 @@ export const sharedViteConfig = { // and let the browser fetch only the ones a menu actually renders. assetsInlineLimit: (filePath) => filePath.includes("/workspace-open-target-icons/") ? false : undefined, + rolldownOptions: { + output: { + // Merge the boot payload's micro-chunks. Rolldown's automatic + // splitting left half the boot-path requests carrying ~2% of the + // bytes (sub-4 KB shared chunks, many below the 1 KiB precompress + // floor), and on the relayed mobile path every request is a full + // worker → DO → tunnel → laptop round trip. The `$initial` tag + // captures exactly the entry's static-import closure, so lazy-route + // and on-demand facades (and the budget's closure walk and + // forbidden-package gates over them) are untouched. Two groups so a + // release that only touches app code leaves the vendor chunk's hash + // — the bulk of the boot bytes — cacheable across updates. + advancedChunks: { + groups: [ + { + name: "boot-vendor", + test: /node_modules/, + tags: ["$initial"], + priority: 2, + minSize: 12 * 1024, + }, + { + name: "boot-app", + tags: ["$initial"], + priority: 1, + minSize: 12 * 1024, + }, + ], + }, + }, + }, }, optimizeDeps: { // The terminal imports xterm lazily when the panel mounts. Pre-optimize From 06c0ebd0aceab79b4ef164d8762fa41b31466a4f Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:55:52 +0200 Subject: [PATCH 4/5] Precompress the document and serve its sidecar on the SPA fallback (plan 007 step 3) .html was missing from COMPRESSIBLE_EXTENSIONS, so the app shell had no .br/.gz sidecar and every cold navigation shipped it re-compressed on the fly at gzip stream quality through the tunnel. The SPA fallback (the document response for every client route a phone opens) also bypassed findPrecompressedStaticFile entirely, reading index.html as utf8. Add .html to the precompress set (index.html.br: 5.2 KB -> 1.6 KB) and route the fallback through the same sidecar-aware serving path as a direct file hit. text/html already passes the precompressed content-type allowlist, so a direct /index.html hit picks the sidecar up unchanged. Co-Authored-By: Claude Fable 5 --- apps/app/src/precompress-app-dist.test.ts | 9 ++- apps/server/src/server.ts | 67 ++++++++++++++--------- scripts/precompress-app-dist.mjs | 1 + 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/apps/app/src/precompress-app-dist.test.ts b/apps/app/src/precompress-app-dist.test.ts index b661a1f6bd..7343760d4d 100644 --- a/apps/app/src/precompress-app-dist.test.ts +++ b/apps/app/src/precompress-app-dist.test.ts @@ -28,10 +28,14 @@ describe("app asset precompression", () => { const distDir = await mkdtemp(resolve(tmpdir(), "bb-precompress-test-")); const compressibleBody = Buffer.from("compressible bb asset\n".repeat(400)); const assetPath = resolve(distDir, "app.js"); + // The document itself: without a sidecar every cold navigation on the + // relayed mobile path ships the shell uncompressed through the tunnel. + const documentPath = resolve(distDir, "index.html"); const smallPath = resolve(distDir, "small.js"); const binaryPath = resolve(distDir, "image.png"); await Promise.all([ writeFile(assetPath, compressibleBody), + writeFile(documentPath, compressibleBody), writeFile(smallPath, "small"), writeFile(binaryPath, compressibleBody), ]); @@ -41,13 +45,16 @@ describe("app asset precompression", () => { distDir, ]); - expect(stdout).toContain("precompressed 1 files (1 br, 1 gzip)"); + expect(stdout).toContain("precompressed 2 files (2 br, 2 gzip)"); await expect( decompressBrotli(await readFile(`${assetPath}.br`)), ).resolves.toEqual(compressibleBody); await expect( decompressGzip(await readFile(`${assetPath}.gz`)), ).resolves.toEqual(compressibleBody); + await expect( + decompressBrotli(await readFile(`${documentPath}.br`)), + ).resolves.toEqual(compressibleBody); await expect(pathExists(`${smallPath}.br`)).resolves.toBe(false); await expect(pathExists(`${binaryPath}.br`)).resolves.toBe(false); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b60e561c77..53656643c8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -634,6 +634,37 @@ export function createApp( ".map": "application/json", }; + const serveStaticAppFile = async (args: { + acceptEncodingHeader: string | undefined; + contentType: string; + filePath: string; + urlPath: string; + }): Promise => { + const precompressedFile = await findPrecompressedStaticFile({ + acceptEncodingHeader: args.acceptEncodingHeader, + contentType: args.contentType, + filePath: args.filePath, + }); + if (precompressedFile !== null) { + const content = await readFile(precompressedFile.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentEncoding: precompressedFile.encoding, + contentLength: precompressedFile.contentLength, + contentType: args.contentType, + urlPath: args.urlPath, + }), + }); + } + const content = await readFile(args.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentType: args.contentType, + urlPath: args.urlPath, + }), + }); + }; + app.get("*", async (context) => { const root = shippedRoot; const urlPath = @@ -645,27 +676,11 @@ export function createApp( try { const fileStat = await stat(filePath); if (fileStat.isFile()) { - const contentType = - MIME[extname(filePath)] ?? "application/octet-stream"; - const precompressedFile = await findPrecompressedStaticFile({ + return await serveStaticAppFile({ acceptEncodingHeader: context.req.header("accept-encoding"), - contentType, + contentType: MIME[extname(filePath)] ?? "application/octet-stream", filePath, - }); - if (precompressedFile !== null) { - const content = await readFile(precompressedFile.filePath); - return new Response(content, { - headers: createStaticResponseHeaders({ - contentEncoding: precompressedFile.encoding, - contentLength: precompressedFile.contentLength, - contentType, - urlPath, - }), - }); - } - const content = await readFile(filePath); - return new Response(content, { - headers: createStaticResponseHeaders({ contentType, urlPath }), + urlPath, }); } } catch { @@ -679,12 +694,14 @@ export function createApp( if (urlPath.startsWith("/assets/")) { return context.notFound(); } - const indexHtml = await readFile(join(root, "index.html"), "utf8"); - return new Response(indexHtml, { - headers: createStaticResponseHeaders({ - contentType: "text/html", - urlPath: "/index.html", - }), + // The SPA fallback is the document response for every client route + // (every thread page a phone opens), so it serves the same sidecar as + // a direct /index.html hit. + return serveStaticAppFile({ + acceptEncodingHeader: context.req.header("accept-encoding"), + contentType: "text/html", + filePath: join(root, "index.html"), + urlPath: "/index.html", }); }); } diff --git a/scripts/precompress-app-dist.mjs b/scripts/precompress-app-dist.mjs index 90a9bf493a..5177b4e7b8 100644 --- a/scripts/precompress-app-dist.mjs +++ b/scripts/precompress-app-dist.mjs @@ -11,6 +11,7 @@ const DEFAULT_COMPRESSION_CONCURRENCY = 8; const MIN_COMPRESS_BYTES = 1024; const COMPRESSIBLE_EXTENSIONS = new Set([ ".css", + ".html", ".js", ".json", ".mjs", From f70f363243d06fd0a00f933b3c86cf10c6158fe6 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 17:31:26 +0200 Subject: [PATCH 5/5] Edge-cacheable app shell: build-id ETag + connect revalidation (plan 007 step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell was no-cache, which apps/connect/src/cache.ts treats as never-cacheable: every cold navigation paid a full worker -> DO -> tunnel -> laptop round trip for the document before the browser learned what to fetch. no-cache existed so a new build is picked up immediately — that property had to survive. Server half: the shell (direct and SPA-fallback, unified behind registerStaticAppRoutes) now carries max-age=300, must-revalidate plus a weak build-id ETag derived from the served file's bytes (index.html embeds every hashed asset URL, so the content hash IS the build id; cached per path, revalidated by size+mtime). If-None-Match answers with an empty 304 carrying the same validator and cache-control. Connect half: serveWithCache learns a revalidated-shell flavor, checked before plain cacheability (the shell's max-age=300 would otherwise be cached without the revalidation its must-revalidate demands). The last confirmed document is stored in caches.default with its origin headers intact — its own max-age bounds storage at <=300s — and every navigation revalidates through the tunnel: the visitor's If-None-Match is forwarded when present (304 relayed), otherwise the stored ETag makes the round trip a 304 and the body is served from the edge, rebuilt pre-encoded exactly like an asset hit. A fresh 200 replaces the stored copy, so a new build takes effect on the next navigation; an origin that stops speaking the contract (dev server) gets its stored copy dropped. Deviation from the plan sketch: one self-describing cache entry (ETag in the stored response's own header, confirmed by the origin before every serve) instead of separate (label, ETag)-keyed body + pointer entries. Equivalent consistency, and it keeps cache.put on the proven clone-of-subrequest path — workerd's put of a header-rewritten rebuild has exactly the encoding ambiguity response-encoding.ts exists to avoid. Old worker + new server skew is safe (the old worker plain-caches the shell for at most 300s); new worker + old server is inert (no ETag, no must-revalidate -> no shell flow). No server<->host-daemon wire change, so no HOST_DAEMON_PROTOCOL_VERSION bump. Verified: new apps/server/src/static-shell.test.ts (sidecar + ETag + 304 on both paths, ETag rotation on a new build); static-cache.test.ts updated from the old no-cache pin; new apps/connect/src/ document-cache.test.ts in real workerd via the tunnel fixture — repeat navigation served from caches.default with only a 304 on the tunnel, build change shipped on the next navigation, visitor 304 relayed. Co-Authored-By: Claude Fable 5 --- apps/connect/src/cache.ts | 132 +++++++++- apps/connect/src/document-cache.test.ts | 228 ++++++++++++++++++ apps/connect/src/worker.ts | 9 +- apps/connect/test/encoding-fixture.ts | 22 +- apps/server/src/server.ts | 279 +++++++++++++++------- apps/server/src/static-shell.test.ts | 117 +++++++++ apps/server/test/app/static-cache.test.ts | 19 +- 7 files changed, 708 insertions(+), 98 deletions(-) create mode 100644 apps/connect/src/document-cache.test.ts create mode 100644 apps/server/src/static-shell.test.ts diff --git a/apps/connect/src/cache.ts b/apps/connect/src/cache.ts index 762d338a94..1250bd1bd6 100644 --- a/apps/connect/src/cache.ts +++ b/apps/connect/src/cache.ts @@ -3,6 +3,14 @@ // the tunnel round-trip entirely — turning a page's hundreds of asset requests // into a handful of dynamic API calls plus edge hits. // +// The app shell (index.html on every client route) gets a second, revalidated +// flavor: the origin serves it with `max-age=300, must-revalidate` plus a +// build-id ETag, so the worker keeps the last confirmed document at the edge +// and asks the laptop only "is still current?" on each navigation. A +// 304 costs the tunnel a handful of header bytes instead of the document, and +// a new build still takes effect on the next navigation because the origin +// answers that conditional request with the fresh 200. +// // Only called AFTER the gate has verified the requester owns the label. Server // cache namespaces remain the bare/full host label exactly as on main; new // machine labels include their ownership generation. Caching is opt-in via the @@ -12,8 +20,17 @@ import { rebuiltResponse } from "./response-encoding.js"; const CACHE_HOST = "https://bb-connect-asset-cache.internal"; +// A separate host keeps shell entries from ever colliding with asset entries +// for the same namespace + path. +const SHELL_CACHE_HOST = "https://bb-connect-shell-cache.internal"; const MIN_CACHEABLE_MAX_AGE = 300; +/** + * The origin fetch for a gated request. `ifNoneMatch` asks the tunnel client + * to make the request conditional so an unchanged shell answers with a 304. + */ +export type FetchOrigin = (init?: { ifNoneMatch: string }) => Promise; + /** Build the edge-cache Request key for a namespace label + visitor URL. */ export function cacheKey(namespace: string, url: URL): Request { return new Request(`${CACHE_HOST}/${namespace}${url.pathname}${url.search}`, { @@ -21,6 +38,14 @@ export function cacheKey(namespace: string, url: URL): Request { }); } +/** Edge-cache key for the revalidated shell copy of a namespace + URL. */ +export function shellCacheKey(namespace: string, url: URL): Request { + return new Request( + `${SHELL_CACHE_HOST}/${namespace}${url.pathname}${url.search}`, + { method: "GET" }, + ); +} + function isCacheable(resp: Response): boolean { if (!resp.ok) return false; if (resp.headers.has("set-cookie")) return false; @@ -36,6 +61,95 @@ export interface CacheResult { response: Response; } +/** + * A response the origin wants cached only under revalidation: a build-id ETag + * plus `must-revalidate` with a short freshness window. The bb server marks + * exactly one response this way — the app shell — but the check is + * header-driven, so any origin (including a port share) opting in with the + * same contract gets the same treatment. Checked before `isCacheable`: the + * shell's `max-age=300` would otherwise be cached plainly and served without + * the revalidation its `must-revalidate` demands. + */ +function isRevalidatableShell(resp: Response): boolean { + if (!resp.ok) return false; + if (resp.headers.has("set-cookie")) return false; + if (resp.headers.get("etag") === null) return false; + const cc = resp.headers.get("cache-control") ?? ""; + if (/\b(no-store|no-cache|private)\b/i.test(cc)) return false; + if (!/\bmust-revalidate\b/i.test(cc)) return false; + const maxAge = cc.match(/max-age=(\d+)/i); + return maxAge !== null && Number(maxAge[1]) >= 1; +} + +/** + * Store the shell response and build the visitor's copy. The clone is stored + * with its origin headers intact — including the ETag the entry is keyed to + * in spirit: a stored shell is only ever served after the origin confirms + * that exact ETag with a 304 — and its `max-age=300` bounds the storage, so + * nothing stale outlives the origin's own freshness window. + */ +function storeShellAndServe( + resp: Response, + namespace: string, + url: URL, + ctx: ExecutionContext, +): CacheResult { + ctx.waitUntil(caches.default.put(shellCacheKey(namespace, url), resp.clone())); + // Same encoding rule as the asset miss below: a body read out of a + // subrequest is already plain bytes, so automatic encoding is correct. + const r = new Response(resp.body, resp); + r.headers.set("x-bb-cache", "miss"); + return { cacheable: true, response: r }; +} + +/** + * A shell copy exists at the edge: revalidate it against the origin before + * serving. The visitor's own If-None-Match wins when present (the origin + * validates it and a relayed 304 is the cheapest possible answer); otherwise + * the stored copy's ETag makes the round trip a 304 whenever the build is + * unchanged. + */ +async function serveRevalidatedShell( + request: Request, + shellHit: Response, + namespace: string, + url: URL, + ctx: ExecutionContext, + fetchOrigin: FetchOrigin, +): Promise { + const storedEtag = shellHit.headers.get("etag"); + const visitorEtag = request.headers.get("if-none-match"); + const conditionalEtag = visitorEtag ?? storedEtag; + const resp = await fetchOrigin( + conditionalEtag === null ? undefined : { ifNoneMatch: conditionalEtag }, + ); + if (resp.status === 304) { + if (visitorEtag !== null) { + // The origin confirmed the visitor's own copy — relay the 304. + const r = rebuiltResponse(null, resp); + r.headers.set("x-bb-cache", "revalidated"); + return { cacheable: true, response: r }; + } + // The stored bytes are still encoded exactly like an asset hit's (the + // cache keeps the origin's encoding), so rebuild as pre-encoded. + // `cacheable: true` is load-bearing beyond refresh semantics: the + // session-refresh path rebuilds non-cacheable responses to append + // Set-Cookie, and that rebuild would strip this body's pre-encoded flag. + const r = rebuiltResponse(shellHit.body, shellHit); + r.headers.set("x-bb-cache", "revalidated"); + return { cacheable: true, response: r }; + } + if (isRevalidatableShell(resp)) { + return storeShellAndServe(resp, namespace, url, ctx); + } + if (resp.ok) { + // The origin stopped speaking the shell contract (say a dev server took + // over the label) — drop the stored copy so requests stop revalidating. + ctx.waitUntil(caches.default.delete(shellCacheKey(namespace, url))); + } + return { cacheable: false, response: resp }; +} + /** * Serve `request` from the edge cache when possible, else run `fetchOrigin` * (the tunnel) and populate the cache when the response is cacheable. @@ -47,7 +161,7 @@ export async function serveWithCache( request: Request, namespace: string, ctx: ExecutionContext, - fetchOrigin: () => Promise, + fetchOrigin: FetchOrigin, ): Promise { if (request.method !== "GET") { return { cacheable: false, response: await fetchOrigin() }; @@ -68,7 +182,23 @@ export async function serveWithCache( return { cacheable: true, response: r }; } + // Not an immutable asset — maybe a previously stored shell document. + const shellHit = await cache.match(shellCacheKey(namespace, url)); + if (shellHit) { + return serveRevalidatedShell( + request, + shellHit, + namespace, + url, + ctx, + fetchOrigin, + ); + } + const resp = await fetchOrigin(); + if (isRevalidatableShell(resp)) { + return storeShellAndServe(resp, namespace, url, ctx); + } if (isCacheable(resp)) { // clone() before the body is consumed by the returned response. ctx.waitUntil(cache.put(key, resp.clone())); diff --git a/apps/connect/src/document-cache.test.ts b/apps/connect/src/document-cache.test.ts new file mode 100644 index 0000000000..4f58046e45 --- /dev/null +++ b/apps/connect/src/document-cache.test.ts @@ -0,0 +1,228 @@ +// The revalidated shell cache, exercised through the real TunnelDO and the +// real serveWithCache inside workerd (miniflare) — the same harness as +// response-encoding.test.ts, because the cache stores still-encoded bytes and +// `encodeBody` exists only in workerd. +// +// The fake tunnel client plays a bb server that speaks the shell contract: +// `max-age=300, must-revalidate` plus a build-id ETag, 304 for a matching +// If-None-Match. The tests pin the design's three properties: a repeat +// navigation is served from caches.default with only a 304 on the tunnel, a +// build change takes effect on the next navigation, and a visitor's own +// conditional request relays the origin's 304. +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; +import { build } from "esbuild"; +import { Miniflare } from "miniflare"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { decodeFrame, encodeFrame, type Frame } from "@bb/tunnel-contract"; + +const SHELL_CACHE_CONTROL = "max-age=300, must-revalidate"; + +const BUILD_A = { + etag: 'W/"build-a"', + html: `bb${"

build a

".repeat(40)}`, +}; +const BUILD_B = { + etag: 'W/"build-b"', + html: `bb${"

build b — new hashes

".repeat(40)}`, +}; + +type ClientWebSocket = NonNullable< + Awaited>["webSocket"] +>; + +let mf: Miniflare; +let tunnel: ClientWebSocket; + +/** What the fake bb server serves right now; tests flip it to ship a build. */ +let currentBuild = BUILD_A; +/** One entry per relayed request: what the origin saw and had to send. */ +const originLog: { ifNoneMatch: string | null; sentBody: boolean }[] = []; + +async function bundleFixture(): Promise { + const result = await build({ + entryPoints: [ + fileURLToPath(new URL("../test/encoding-fixture.ts", import.meta.url)), + ], + bundle: true, + format: "esm", + target: "esnext", + conditions: ["workerd", "worker", "browser"], + write: false, + }); + return result.outputFiles[0].text; +} + +/** A tunnel client whose origin serves the shell contract for every path. */ +function serveShellOverTunnel(ws: ClientWebSocket): void { + const send = (frame: Frame) => ws.send(new Uint8Array(encodeFrame(frame))); + ws.addEventListener("message", (event) => { + if (typeof event.data === "string") return; + const frame = decodeFrame(event.data as ArrayBuffer); + if (frame.type !== "open-http") return; + const ifNoneMatch = + frame.headers.find(([name]) => name.toLowerCase() === "if-none-match")?.[1] ?? + null; + if (ifNoneMatch === currentBuild.etag) { + originLog.push({ ifNoneMatch, sentBody: false }); + send({ + type: "resp-head", + streamId: frame.streamId, + status: 304, + headers: [ + ["etag", currentBuild.etag], + ["cache-control", SHELL_CACHE_CONTROL], + ], + }); + send({ type: "body-end", streamId: frame.streamId }); + return; + } + originLog.push({ ifNoneMatch, sentBody: true }); + const gzip = gzipSync(Buffer.from(currentBuild.html)); + send({ + type: "resp-head", + streamId: frame.streamId, + status: 200, + headers: [ + ["content-type", "text/html; charset=utf-8"], + ["content-encoding", "gzip"], + ["content-length", String(gzip.byteLength)], + ["cache-control", SHELL_CACHE_CONTROL], + ["etag", currentBuild.etag], + ], + }); + send({ + type: "body-chunk", + streamId: frame.streamId, + data: new Uint8Array(gzip), + }); + send({ type: "body-end", streamId: frame.streamId }); + }); +} + +async function get( + path: string, + headers: Record = {}, +): Promise<{ + status: number; + cacheMarker: string | null; + etag: string | null; + body: string; +}> { + const res = await mf.dispatchFetch(`https://relay.test${path}`, { + headers: { "accept-encoding": "gzip", ...headers }, + }); + return { + status: res.status, + cacheMarker: res.headers.get("x-bb-cache"), + etag: res.headers.get("etag"), + body: Buffer.from(await res.arrayBuffer()).toString("utf8"), + }; +} + +/** Cache writes ride ctx.waitUntil; poll the fixture's probe before relying on them. */ +async function waitForShellCached(path: string): Promise { + for (let i = 0; i < 50; i += 1) { + const res = await mf.dispatchFetch( + `https://relay.test/shell-cached?for=${encodeURIComponent(path)}`, + ); + if (res.status === 200) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`shell copy for ${path} never landed in caches.default`); +} + +beforeAll(async () => { + mf = new Miniflare({ + modules: [ + { + type: "ESModule", + path: "/fixture.js", + contents: await bundleFixture(), + }, + ], + modulesRoot: "/", + scriptPath: "/fixture.js", + compatibilityDate: "2026-06-11", + compatibilityFlags: ["nodejs_compat"], + durableObjects: { TUNNEL_DO: "TunnelDO" }, + d1Databases: { DB: "fixture-db" }, + bindings: { + BASE_DOMAIN: "relay.test", + BETTER_AUTH_SECRET: "fixture-secret", + GZIP_BODY_B64: gzipSync(Buffer.from("unused")).toString("base64"), + }, + }); + await mf.ready; + + const dial = await mf.dispatchFetch("https://relay.test/__tunnel", { + headers: { Upgrade: "websocket" }, + }); + if (!dial.webSocket) throw new Error(`tunnel dial failed: ${dial.status}`); + tunnel = dial.webSocket; + tunnel.accept(); + serveShellOverTunnel(tunnel); +}, 60_000); + +afterAll(async () => { + tunnel?.close(); + await mf?.dispose(); +}); + +describe("revalidated shell cache", () => { + it("serves repeats from caches.default with only a 304 on the tunnel, and ships a new build on the next navigation", async () => { + // Cold: full document through the tunnel, stored at the edge. + const cold = await get("/threads/t1"); + expect(cold.status).toBe(200); + expect(cold.body).toBe(BUILD_A.html); + expect(cold.cacheMarker).toBe("miss"); + expect(originLog.at(-1)).toEqual({ ifNoneMatch: null, sentBody: true }); + await waitForShellCached("/threads/t1"); + + // Repeat: the origin only confirms the ETag; the body comes from the + // edge cache. + const repeat = await get("/threads/t1"); + expect(repeat.status).toBe(200); + expect(repeat.body).toBe(BUILD_A.html); + expect(repeat.cacheMarker).toBe("revalidated"); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_A.etag, + sentBody: false, + }); + + // Ship a build: the same conditional request now returns the fresh 200, + // so the next navigation renders the new shell. + currentBuild = BUILD_B; + const upgraded = await get("/threads/t1"); + expect(upgraded.status).toBe(200); + expect(upgraded.body).toBe(BUILD_B.html); + expect(upgraded.etag).toBe(BUILD_B.etag); + expect(upgraded.cacheMarker).toBe("miss"); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_A.etag, + sentBody: true, + }); + await waitForShellCached("/threads/t1"); + + // And the new build revalidates from the edge like the old one did. + const settled = await get("/threads/t1"); + expect(settled.body).toBe(BUILD_B.html); + expect(settled.cacheMarker).toBe("revalidated"); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_B.etag, + sentBody: false, + }); + }, 30_000); + + it("relays the origin's 304 when the visitor presents a current validator", async () => { + currentBuild = BUILD_B; + const res = await get("/threads/t1", { "if-none-match": BUILD_B.etag }); + expect(res.status).toBe(304); + expect(res.body).toBe(""); + expect(res.cacheMarker).toBe("revalidated"); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_B.etag, + sentBody: false, + }); + }, 30_000); +}); diff --git a/apps/connect/src/worker.ts b/apps/connect/src/worker.ts index 4bc7896d59..32ce7cf89b 100644 --- a/apps/connect/src/worker.ts +++ b/apps/connect/src/worker.ts @@ -502,7 +502,14 @@ export default { request, cacheNamespace(routingKey, target), ctx, - () => stub.fetch(doRequest), + (init) => { + if (init === undefined) return stub.fetch(doRequest); + // Shell revalidation: the edge holds the last confirmed document, so + // ask the origin to confirm its ETag instead of resending the body. + const headers = new Headers(doRequest.headers); + headers.set("if-none-match", init.ifNoneMatch); + return stub.fetch(new Request(doRequest, { headers })); + }, ); let response = cached.response; // Tunnel down + a browser navigation → the styled offline page, using the diff --git a/apps/connect/test/encoding-fixture.ts b/apps/connect/test/encoding-fixture.ts index 02226767b7..2e6df09622 100644 --- a/apps/connect/test/encoding-fixture.ts +++ b/apps/connect/test/encoding-fixture.ts @@ -5,7 +5,7 @@ // // This wires up the production pieces themselves: the real TunnelDO (driven by // a fake tunnel client over a real WebSocket) behind the real serveWithCache. -import { cacheKey, serveWithCache } from "../src/cache.js"; +import { cacheKey, serveWithCache, shellCacheKey } from "../src/cache.js"; export { TunnelDO } from "../src/tunnel-do.js"; @@ -67,6 +67,19 @@ export default { }); } + // Probe: whether the revalidated shell copy for a path has landed in the + // edge cache yet. cache writes ride ctx.waitUntil, so tests poll this + // instead of racing the put. + if (url.pathname === "/shell-cached") { + const target = url.searchParams.get("for") ?? "/"; + const cached = await caches.default.match( + shellCacheKey(NAMESPACE, new URL(`${url.origin}${target}`)), + ); + return new Response(cached ? "cached" : "absent", { + status: cached ? 200 : 404, + }); + } + // Control: the pre-fix cache-hit rebuild, over the entry serveWithCache // stored for another path. if (url.pathname === "/legacy-cache-hit") { @@ -79,7 +92,12 @@ export default { } return ( - await serveWithCache(request, NAMESPACE, ctx, () => stub.fetch(request)) + await serveWithCache(request, NAMESPACE, ctx, (init) => { + if (init === undefined) return stub.fetch(request); + const headers = new Headers(request.headers); + headers.set("if-none-match", init.ifNoneMatch); + return stub.fetch(new Request(request, { headers })); + }) ).response; }, }; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 53656643c8..8bd7efc68f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,4 +1,5 @@ import { createNodeWebSocket } from "@hono/node-ws"; +import { createHash } from "node:crypto"; import { readFile, stat } from "node:fs/promises"; import { performance } from "node:perf_hooks"; import { extname, join, resolve } from "node:path"; @@ -133,13 +134,21 @@ interface StaticResponseHeadersArgs { contentEncoding?: string; contentLength?: number; contentType: string; + /** Present only for the app shell; other static files rely on hashes/TTLs. */ + etag?: string; urlPath: string; } -// `no-cache` (not `no-store`): the document is revalidated on every -// navigation, so a new build is picked up immediately, but WebKit may still -// keep the page in the back/forward cache and restore it without a reload. -const STATIC_INDEX_CACHE_CONTROL = "no-cache"; +// The document travels with a build-id ETag (see shellEtag): the browser may +// reuse it for five minutes, then must revalidate — an If-None-Match answered +// with a 304, which costs the connect tunnel a handful of header bytes — and +// the connect worker revalidates its edge copy on every navigation, so a new +// build still takes effect on the next navigation there. This replaces +// `no-cache`, whose "new build picked up immediately" property the ETag +// preserves without re-sending the document each time. Still not `no-store`: +// WebKit may keep the page in the back/forward cache and restore it without a +// reload. +const STATIC_INDEX_CACHE_CONTROL = "max-age=300, must-revalidate"; const STATIC_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"; // Icons and manifests under public/ are not content-hashed but change only // with a release; a day of caching keeps favicon/badge flips and PWA @@ -188,6 +197,9 @@ function createStaticResponseHeaders(args: StaticResponseHeadersArgs): Headers { const headers = new Headers(); headers.set("content-type", args.contentType); headers.set("cache-control", staticCacheControlForPath(args.urlPath)); + if (args.etag !== undefined) { + headers.set("etag", args.etag); + } if (args.contentEncoding !== undefined) { headers.set("content-encoding", args.contentEncoding); headers.set("vary", "Accept-Encoding"); @@ -198,6 +210,178 @@ function createStaticResponseHeaders(args: StaticResponseHeadersArgs): Headers { return headers; } +/** + * Build-id ETag for the app shell, derived from the served file's bytes: + * index.html embeds every content-hashed asset URL, so its content changes + * exactly when a build does. Cached per path and revalidated by (size, + * mtime) so an in-place dist swap gets a fresh tag without hashing every + * request. Weak, because the precompressed sidecars are equivalent — not + * byte-identical — representations of the same document. + */ +const shellEtagCache = new Map< + string, + { etag: string; mtimeMs: number; size: number } +>(); + +async function shellEtag(filePath: string): Promise { + try { + const fileStat = await stat(filePath); + const cached = shellEtagCache.get(filePath); + if ( + cached !== undefined && + cached.size === fileStat.size && + cached.mtimeMs === fileStat.mtimeMs + ) { + return cached.etag; + } + const digest = createHash("sha256") + .update(await readFile(filePath)) + .digest("hex"); + const etag = `W/"${digest.slice(0, 32)}"`; + shellEtagCache.set(filePath, { + etag, + mtimeMs: fileStat.mtimeMs, + size: fileStat.size, + }); + return etag; + } catch { + // Unreadable file: serve without a validator rather than failing the + // request here; the read below will surface the real error. + return undefined; + } +} + +/** RFC 9110 §13.1.2: If-None-Match always compares weakly for GET. */ +export function ifNoneMatchSatisfied( + ifNoneMatchHeader: string, + etag: string, +): boolean { + if (ifNoneMatchHeader.trim() === "*") return true; + const opaque = (tag: string): string => tag.trim().replace(/^W\//u, ""); + const target = opaque(etag); + return ifNoneMatchHeader + .split(",") + .some((candidate) => opaque(candidate) === target); +} + +const STATIC_MIME_TYPES: Record = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".webmanifest": "application/manifest+json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".webp": "image/webp", + ".map": "application/json", +}; + +/** + * Serves the built app from `staticDir`: content-hashed assets, public files, + * and the shell (index.html — directly and as the single-page-app fallback + * for every client route). Registered by createApp; exported so tests can + * exercise the shell contract (sidecar, ETag, 304) against a bare Hono app. + */ +export function registerStaticAppRoutes(app: Hono, staticDir: string): void { + const root = resolve(staticDir); + + const serveStaticAppFile = async (args: { + acceptEncodingHeader: string | undefined; + contentType: string; + filePath: string; + ifNoneMatchHeader: string | undefined; + urlPath: string; + }): Promise => { + // Only the shell carries a validator: assets are immutable by hash and + // public files by TTL, but the document must revalidate cheaply — a 304 + // here is what keeps `max-age=300, must-revalidate` as prompt as the old + // `no-cache` without resending the document every navigation. + const etag = + args.contentType === "text/html" + ? await shellEtag(args.filePath) + : undefined; + if ( + etag !== undefined && + args.ifNoneMatchHeader !== undefined && + ifNoneMatchSatisfied(args.ifNoneMatchHeader, etag) + ) { + const headers = new Headers(); + headers.set("cache-control", staticCacheControlForPath(args.urlPath)); + headers.set("etag", etag); + return new Response(null, { status: 304, headers }); + } + const precompressedFile = await findPrecompressedStaticFile({ + acceptEncodingHeader: args.acceptEncodingHeader, + contentType: args.contentType, + filePath: args.filePath, + }); + if (precompressedFile !== null) { + const content = await readFile(precompressedFile.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentEncoding: precompressedFile.encoding, + contentLength: precompressedFile.contentLength, + contentType: args.contentType, + etag, + urlPath: args.urlPath, + }), + }); + } + const content = await readFile(args.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentType: args.contentType, + etag, + urlPath: args.urlPath, + }), + }); + }; + + app.get("*", async (context) => { + const urlPath = context.req.path === "/" ? "/index.html" : context.req.path; + const filePath = join(root, urlPath); + if (!filePath.startsWith(root)) { + return context.notFound(); + } + try { + const fileStat = await stat(filePath); + if (fileStat.isFile()) { + return await serveStaticAppFile({ + acceptEncodingHeader: context.req.header("accept-encoding"), + contentType: + STATIC_MIME_TYPES[extname(filePath)] ?? "application/octet-stream", + filePath, + ifNoneMatchHeader: context.req.header("if-none-match"), + urlPath, + }); + } + } catch { + // File not found — fall through to SPA fallback + } + // /assets/ holds content-hashed build output, never a client route, so + // a miss there is a stale reference rather than a page to render. The + // single-page-app fallback would answer it with index.html at status + // 200, and the browser would report a confusing MIME type error for a + // script instead of a plain 404. Mirrors the /api/v1/* guard above. + if (urlPath.startsWith("/assets/")) { + return context.notFound(); + } + // The SPA fallback is the document response for every client route + // (every thread page a phone opens), so it serves the same sidecar and + // validator as a direct /index.html hit. + return serveStaticAppFile({ + acceptEncodingHeader: context.req.header("accept-encoding"), + contentType: "text/html", + filePath: join(root, "index.html"), + ifNoneMatchHeader: context.req.header("if-none-match"), + urlPath: "/index.html", + }); + }); +} + function canServePrecompressedStaticFile(contentType: string): boolean { return ( contentType.startsWith("text/") || @@ -618,92 +802,7 @@ export function createApp( } if (options?.staticDir) { - const shippedRoot = resolve(options.staticDir); - const MIME: Record = { - ".html": "text/html", - ".js": "application/javascript", - ".css": "text/css", - ".json": "application/json", - ".webmanifest": "application/manifest+json", - ".png": "image/png", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".webp": "image/webp", - ".map": "application/json", - }; - - const serveStaticAppFile = async (args: { - acceptEncodingHeader: string | undefined; - contentType: string; - filePath: string; - urlPath: string; - }): Promise => { - const precompressedFile = await findPrecompressedStaticFile({ - acceptEncodingHeader: args.acceptEncodingHeader, - contentType: args.contentType, - filePath: args.filePath, - }); - if (precompressedFile !== null) { - const content = await readFile(precompressedFile.filePath); - return new Response(content, { - headers: createStaticResponseHeaders({ - contentEncoding: precompressedFile.encoding, - contentLength: precompressedFile.contentLength, - contentType: args.contentType, - urlPath: args.urlPath, - }), - }); - } - const content = await readFile(args.filePath); - return new Response(content, { - headers: createStaticResponseHeaders({ - contentType: args.contentType, - urlPath: args.urlPath, - }), - }); - }; - - app.get("*", async (context) => { - const root = shippedRoot; - const urlPath = - context.req.path === "/" ? "/index.html" : context.req.path; - const filePath = join(root, urlPath); - if (!filePath.startsWith(root)) { - return context.notFound(); - } - try { - const fileStat = await stat(filePath); - if (fileStat.isFile()) { - return await serveStaticAppFile({ - acceptEncodingHeader: context.req.header("accept-encoding"), - contentType: MIME[extname(filePath)] ?? "application/octet-stream", - filePath, - urlPath, - }); - } - } catch { - // File not found — fall through to SPA fallback - } - // /assets/ holds content-hashed build output, never a client route, so - // a miss there is a stale reference rather than a page to render. The - // single-page-app fallback would answer it with index.html at status - // 200, and the browser would report a confusing MIME type error for a - // script instead of a plain 404. Mirrors the /api/v1/* guard above. - if (urlPath.startsWith("/assets/")) { - return context.notFound(); - } - // The SPA fallback is the document response for every client route - // (every thread page a phone opens), so it serves the same sidecar as - // a direct /index.html hit. - return serveStaticAppFile({ - acceptEncodingHeader: context.req.header("accept-encoding"), - contentType: "text/html", - filePath: join(root, "index.html"), - urlPath: "/index.html", - }); - }); + registerStaticAppRoutes(app, options.staticDir); } return { diff --git a/apps/server/src/static-shell.test.ts b/apps/server/src/static-shell.test.ts new file mode 100644 index 0000000000..573a545db1 --- /dev/null +++ b/apps/server/src/static-shell.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { brotliCompressSync } from "node:zlib"; +import { Hono } from "hono"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ifNoneMatchSatisfied, registerStaticAppRoutes } from "./server.js"; + +/** + * The shell contract the connect worker's edge cache builds on: the document + * (served directly and as the SPA fallback for every client route) carries a + * build-id ETag and `max-age=300, must-revalidate`, answers If-None-Match + * with a cheap 304, and ships its precompressed sidecar when the client + * accepts it. A regression here silently turns every relayed navigation back + * into a full-document tunnel round trip. + */ +describe("app shell serving", () => { + const shellHtml = "bb

build-a

"; + const shellBrotli = brotliCompressSync(Buffer.from(shellHtml)); + let dir: string; + let app: Hono; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "bb-static-shell-")); + await writeFile(join(dir, "index.html"), shellHtml); + await writeFile(join(dir, "index.html.br"), shellBrotli); + app = new Hono(); + registerStaticAppRoutes(app, dir); + }); + + it("serves the brotli sidecar with the shell headers, directly and on the SPA fallback", async () => { + for (const path of ["/", "/threads/some-thread"]) { + const res = await app.request(path, { + headers: { "accept-encoding": "br, gzip" }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe("br"); + expect(res.headers.get("content-type")).toBe("text/html"); + expect(res.headers.get("cache-control")).toBe( + "max-age=300, must-revalidate", + ); + expect(res.headers.get("etag")).toMatch(/^W\/"[0-9a-f]{32}"$/u); + expect(Buffer.from(await res.arrayBuffer())).toEqual(shellBrotli); + } + }); + + it("serves the identity document when the client accepts no encodings", async () => { + const res = await app.request("/threads/t"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(res.headers.get("etag")).toMatch(/^W\//u); + expect(await res.text()).toBe(shellHtml); + }); + + it("answers a matching If-None-Match with an empty 304 on both paths", async () => { + const first = await app.request("/"); + const etag = first.headers.get("etag"); + expect(etag).not.toBeNull(); + + for (const path of ["/", "/threads/some-thread"]) { + const res = await app.request(path, { + headers: { "accept-encoding": "br", "if-none-match": etag ?? "" }, + }); + expect(res.status).toBe(304); + expect(res.headers.get("etag")).toBe(etag); + expect(res.headers.get("cache-control")).toBe( + "max-age=300, must-revalidate", + ); + expect((await res.arrayBuffer()).byteLength).toBe(0); + } + }); + + it("serves the full document for a stale validator", async () => { + const res = await app.request("/", { + headers: { "if-none-match": 'W/"0000000000000000000000000000dead"' }, + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe(shellHtml); + }); + + it("rotates the ETag when a new build lands, so old validators refetch", async () => { + const first = await app.request("/"); + const oldEtag = first.headers.get("etag") ?? ""; + + const nextBuild = + "bb

build-b with new hashed assets

"; + await writeFile(join(dir, "index.html"), nextBuild); + + const res = await app.request("/", { + headers: { "if-none-match": oldEtag }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("etag")).not.toBe(oldEtag); + expect(await res.text()).toBe(nextBuild); + }); + + it("keeps /assets/ misses as 404 instead of the SPA fallback", async () => { + const res = await app.request("/assets/stale-chunk.js"); + expect(res.status).toBe(404); + }); +}); + +describe("ifNoneMatchSatisfied", () => { + const etag = 'W/"abc123"'; + + it("compares weakly and accepts lists and wildcards", () => { + expect(ifNoneMatchSatisfied('W/"abc123"', etag)).toBe(true); + expect(ifNoneMatchSatisfied('"abc123"', etag)).toBe(true); + expect(ifNoneMatchSatisfied('"zzz", W/"abc123"', etag)).toBe(true); + expect(ifNoneMatchSatisfied("*", etag)).toBe(true); + }); + + it("rejects a different validator", () => { + expect(ifNoneMatchSatisfied('W/"other"', etag)).toBe(false); + expect(ifNoneMatchSatisfied('"abc1234"', etag)).toBe(false); + }); +}); diff --git a/apps/server/test/app/static-cache.test.ts b/apps/server/test/app/static-cache.test.ts index 12180811ca..26ac7d523e 100644 --- a/apps/server/test/app/static-cache.test.ts +++ b/apps/server/test/app/static-cache.test.ts @@ -38,13 +38,24 @@ describe("production static cache headers", () => { const harness = await createTestAppHarness(); const serverApp = createApp(harness.deps, { staticDir }); try { - // `no-cache` revalidates on every navigation but, unlike `no-store`, - // leaves the document eligible for the WebKit back/forward cache. + // The shell travels with max-age=300 + must-revalidate + a build-id + // ETag: browsers and the connect edge revalidate with If-None-Match + // (a cheap 304) instead of refetching the document, and unlike + // `no-store` it stays eligible for the WebKit back/forward cache. const rootResponse = await serverApp.app.request("/"); - expect(rootResponse.headers.get("cache-control")).toBe("no-cache"); + expect(rootResponse.headers.get("cache-control")).toBe( + "max-age=300, must-revalidate", + ); + expect(rootResponse.headers.get("etag")).toMatch(/^W\/"[0-9a-f]{32}"$/u); const fallbackResponse = await serverApp.app.request("/threads/thr_123"); - expect(fallbackResponse.headers.get("cache-control")).toBe("no-cache"); + expect(fallbackResponse.headers.get("cache-control")).toBe( + "max-age=300, must-revalidate", + ); + // Same document, same validator: the fallback IS the shell. + expect(fallbackResponse.headers.get("etag")).toBe( + rootResponse.headers.get("etag"), + ); const assetResponse = await serverApp.app.request( "/assets/index-test.js",