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/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), + ); }, }, }; 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 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/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. */ 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 b60e561c77..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,75 +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", - }; - - 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()) { - const contentType = - MIME[extname(filePath)] ?? "application/octet-stream"; - const precompressedFile = await findPrecompressedStaticFile({ - acceptEncodingHeader: context.req.header("accept-encoding"), - contentType, - 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 }), - }); - } - } 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(); - } - const indexHtml = await readFile(join(root, "index.html"), "utf8"); - return new Response(indexHtml, { - headers: createStaticResponseHeaders({ - contentType: "text/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", 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",