From 64d44028ccf00e0f4654932178ffefc365a4526e Mon Sep 17 00:00:00 2001 From: David Date: Tue, 10 Nov 2026 20:27:28 +0100 Subject: [PATCH 1/2] fix(routing): preserve intentional native Worker 404 responses Fixes #775 --- packages/void/src/router/compile.ts | 20 ++- packages/void/src/runtime/routing-pages.ts | 4 +- packages/void/src/runtime/routing-worker.ts | 4 + packages/void/src/runtime/routing.ts | 115 +++++++++++++++++- .../native-asset-cache-policy.test.ts | 63 +++++++++- packages/void/test/unit/compile.test.ts | 8 +- packages/void/test/unit/pages-compile.test.ts | 2 +- 7 files changed, 201 insertions(+), 15 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 65e4033b5..a9860a215 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -474,6 +474,14 @@ export function compileRouteTable( lines.push(''); } + const trackAssetMisses = !options.nodeTarget && !managedDispatch; + const registerNotFound = (handler: string): void => { + const tracked = trackAssetMisses ? `__routingRuntime.trackNotFound(${handler})` : handler; + lines.push(`app.notFound(${tracked});`); + }; + if (trackAssetMisses) { + lines.push('app.notFound(__routingRuntime.trackNotFound());'); + } if ( pages && (scan.pages.pages.length > 0 || @@ -484,8 +492,8 @@ export function compileRouteTable( const pagePatterns = options.trailingSlash ? `[${scan.pages.pages.map((page) => patternToRegex(page.pattern)).join(', ')}]` : 'undefined'; - lines.push( - `app.notFound(__createPagesFallback({ ${createBoundaryManifest(scan.pages).root ? 'pageNotFound: __voidRootNotFound, ' : ''}routing: __routingRuntime, errors: __errorRenderer, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, trailingSlash: ${options.trailingSlash === true}, pagePatterns: ${pagePatterns} }));`, + registerNotFound( + `__createPagesFallback({ ${createBoundaryManifest(scan.pages).root ? 'pageNotFound: __voidRootNotFound, ' : ''}routing: __routingRuntime, errors: __errorRenderer, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, trailingSlash: ${options.trailingSlash === true}, pagePatterns: ${pagePatterns} })`, ); } @@ -495,8 +503,8 @@ export function compileRouteTable( lines.push( `const getClientAssetTags = __createClientAssetTags(${JSON.stringify({ entryId: ssr.clientEntryRelativePath, entryUrl: '/' + ssr.clientEntryRelativePath, kind: 'ssr', dev: options.dev === true, reactRefresh: options.reactRefresh })});`, ); - lines.push( - `app.notFound(__createSsrFallback({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, load: () => import(${JSON.stringify(ssr.serverEntryPath)}), assets: getClientAssetTags }));`, + registerNotFound( + `__createSsrFallback({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, load: () => import(${JSON.stringify(ssr.serverEntryPath)}), assets: getClientAssetTags })`, ); } else if ( emitEdgeRules && @@ -511,8 +519,8 @@ export function compileRouteTable( )) ) ) { - lines.push( - `app.notFound(__createAssetNotFound({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson} }));`, + registerNotFound( + `__createAssetNotFound({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson} })`, ); } diff --git a/packages/void/src/runtime/routing-pages.ts b/packages/void/src/runtime/routing-pages.ts index 2596fabe8..f912ed21b 100644 --- a/packages/void/src/runtime/routing-pages.ts +++ b/packages/void/src/runtime/routing-pages.ts @@ -317,7 +317,9 @@ export function createSsrFallback(options: { if (typeof render !== 'function') { throw new Error('ssr: Invalid entry. Export render(c, assetTags).'); } - return render(c, await options.assets(c.req.raw, c.env)); + return options.routing.preserveNotFound( + await render(c, await options.assets(c.req.raw, c.env)), + ); }; } export function createAssetNotFound(options: { diff --git a/packages/void/src/runtime/routing-worker.ts b/packages/void/src/runtime/routing-worker.ts index 3e8d85f9c..4b987fc62 100644 --- a/packages/void/src/runtime/routing-worker.ts +++ b/packages/void/src/runtime/routing-worker.ts @@ -2,6 +2,9 @@ import { redirectToHttps } from '@void/edge'; import type { ExecutionContext } from 'hono'; import { createManifestLoader, type ManifestLoader } from './routing-assets.ts'; +// Native Void routing records intentional 404s before Hono wraps HEAD responses. +export const intentionalNotFoundRequests: WeakSet = new WeakSet(); + export type AssetBinding = { fetch: (request: Request) => Promise }; export type RuntimeEnvironment = Record & { ASSETS?: AssetBinding }; export type AssetFallbackOptions = { @@ -106,6 +109,7 @@ export function createAssetFallback( return async (request, env, response) => { if ( response.status !== 404 || + intentionalNotFoundRequests.has(request) || !env.ASSETS || (request.method !== 'GET' && request.method !== 'HEAD') ) { diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 38659ab1b..25c12112d 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -6,7 +6,7 @@ import { mergeQueryIntoLocation, substituteParams, } from '@void/edge'; -import type { Context, Hono, MiddlewareHandler } from 'hono'; +import type { Context, Hono, MiddlewareHandler, NotFoundHandler } from 'hono'; import type { TypedResponse } from 'hono'; import type { RedirectStatusCode } from 'hono/utils/http-status'; import type { RouteDestination, RouteUrlInput } from 'void/routes'; @@ -25,6 +25,7 @@ import { convertReturnValue } from './response.ts'; import { toUrlString } from './route-url.ts'; import { ASSET_EXTENSIONS } from './routing-assets.ts'; import { markRouteHandler } from './routing-pages.ts'; +import { intentionalNotFoundRequests } from './routing-worker.ts'; import { isUnchangedStaticAsset, markStaticAsset } from './static-asset-marker.ts'; export { createErrorRenderer } from './routing-errors.ts'; @@ -50,6 +51,8 @@ export type RoutingContext = Context & { export type RoutingRuntime = { requestContext: AsyncLocalStorage; contextMiddleware: MiddlewareHandler; + trackNotFound: (handler?: NotFoundHandler) => NotFoundHandler; + preserveNotFound: (response: Response) => Response; requestMiddleware: MiddlewareHandler; routingMiddleware: (rules: Array) => MiddlewareHandler; headerMiddleware: (rules: Array, phase?: 'auth-error') => MiddlewareHandler; @@ -75,6 +78,68 @@ export function createRoutingRuntime( // Contexts whose inner header pass ran after next(). A handler that throws // past it, such as notFound, leaves the context out. const headerApplied = new WeakSet(); + const explicitNotFound = new WeakSet(); + let trackAssetMisses = false; + const intentionalResponses = new WeakSet(); + const routeMisses = new WeakMap>(); + const recordMiss = (c: Context, response: Response): void => { + const misses = routeMisses.get(c) ?? []; + if (misses.includes(response)) { + return; + } + misses.push(response); + routeMisses.set(c, misses); + trackResponseCopies(c); + // Fetch's clone tees the body, so neither clone shares its former stream. + if (Object.isExtensible(response)) { + const clone = response.clone; + Object.defineProperty(response, 'clone', { + configurable: true, + writable: true, + value(this: Response) { + const copy = clone.call(this); + if (this === response) { + recordMiss(c, copy); + } + return copy; + }, + }); + } + }; + const isRouteMiss = (c: Context, response: Response): boolean => + routeMisses + .get(c) + ?.some((miss) => miss === response || (miss.body !== null && miss.body === response.body)) === + true; + const tracksResponseCopies = new WeakSet(); + const trackResponseCopies = (c: Context): void => { + if (tracksResponseCopies.has(c)) { + return; + } + tracksResponseCopies.add(c); + // Carry provenance to the response Hono exposes after assignment or a header edit. + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(c), 'res'); + if (descriptor?.get && descriptor.set) { + const set = descriptor.set; + Object.defineProperty(c, 'res', { + ...descriptor, + set(response: Response | undefined) { + set.call(c, response); + if (response && isRouteMiss(c, response) && c.res !== response) { + recordMiss(c, c.res); + } + }, + }); + } + const header = c.header.bind(c); + c.header = (...args) => { + const before = c.finalized ? c.res : undefined; + header(...args); + if (before && c.res !== before && isRouteMiss(c, before)) { + recordMiss(c, c.res); + } + }; + }; const warnedPatterns = new Set(); const requestContext = new AsyncLocalStorage(); const assetExtensions = options.blockedAssetExtensions ?? ASSET_EXTENSIONS; @@ -104,6 +169,13 @@ export function createRoutingRuntime( // The enclosing response may depend on auth even if middleware clones or replaces it. authDependentRequests.add(c.req.raw); } + if (trackAssetMisses && response.status === 404) { + if (intentionalNotFoundRequests.has(request)) { + intentionalResponses.add(response); + } else { + recordMiss(c, response); + } + } return response; }; const rememberOriginal = (request: Request, c: Context): void => { @@ -173,6 +245,19 @@ export function createRoutingRuntime( }; const contextMiddleware: MiddlewareHandler = async (context, next) => { const c = context as RoutingContext; + if (trackAssetMisses) { + intentionalNotFoundRequests.delete(c.req.raw); + const notFound = c.notFound.bind(c); + // Hono calls its fallback directly when the router runs out of handlers. + c.notFound = () => { + explicitNotFound.add(c); + try { + return notFound(); + } finally { + explicitNotFound.delete(c); + } + }; + } if (options.managedDispatch && !originalUrls.has(c.req.raw)) { const original = c.req.raw.headers.get('X-Void-Original-URL'); if (original != null) { @@ -222,10 +307,38 @@ export function createRoutingRuntime( c.originalUrl = () => originalUrls.get(c.req.raw) ?? null; c.isRewritten = () => originalUrls.has(c.req.raw); await next(); + if (!trackAssetMisses) { + return; + } + if (c.res.status === 404 && !isRouteMiss(c, c.res)) { + intentionalNotFoundRequests.add(c.req.raw); + } }; return { requestContext, contextMiddleware, + trackNotFound: (handler = (c) => c.text('404 Not Found', 404)) => { + trackAssetMisses = true; + return (c) => { + const explicit = explicitNotFound.has(c); + const response = handler(c); + if (explicit) { + return response; + } + return Promise.resolve(response).then((miss) => { + if (miss.status === 404 && !intentionalResponses.has(miss)) { + recordMiss(c, miss); + } + return miss; + }); + }; + }, + preserveNotFound: (response) => { + if (trackAssetMisses && response.status === 404) { + intentionalResponses.add(response); + } + return response; + }, requestMiddleware: async (c, next) => { await requestContext.run( options.nodeTarget diff --git a/packages/void/test/integration/native-asset-cache-policy.test.ts b/packages/void/test/integration/native-asset-cache-policy.test.ts index 0f79c3d53..567e89ceb 100644 --- a/packages/void/test/integration/native-asset-cache-policy.test.ts +++ b/packages/void/test/integration/native-asset-cache-policy.test.ts @@ -162,6 +162,16 @@ beforeAll(async () => { join(assets, '.vite/manifest.json'), JSON.stringify({ ...manifest, stable: stableEntry }), ); + mkdirSync(join(assets, 'api')); + writeFileSync(join(assets, 'api/data.json'), '{"static":"api"}'); + mkdirSync(join(assets, 'api/files')); + writeFileSync(join(assets, 'api/files/open.json'), '{"static":"api miss"}'); + writeFileSync(join(assets, 'api/files/hidden.json'), '{"static":"hidden"}'); + writeFileSync(join(assets, 'api/files/probe.json'), '{"static":"probe"}'); + writeFileSync(join(assets, 'api/files/clone.json'), '{"static":"clone"}'); + writeFileSync(join(assets, 'rewrite-miss'), 'rewrite asset'); + mkdirSync(join(assets, 'files')); + writeFileSync(join(assets, 'files/open.json'), '{"static":"open"}'); mkdirSync(join(assets, 'static'), { recursive: true }); for (const file of ['app-settings.json', 'client.js', 'page-abcdefgh.html']) { writeFileSync( @@ -194,11 +204,35 @@ beforeAll(async () => { ] }])); app.get('/health', (c) => c.text('healthy', 200, { 'cache-control': 'private, no-store' })); } - app.notFound(mode === 'pages' + app.notFound(routing.trackNotFound(mode === 'pages' ? createPagesFallback({ routing, assetsDir: 'static', errors: createErrorRenderer({ projectRoot: '/fixture', homeDir: '/home' }) }) : mode === 'ssr' ? createSsrFallback({ routing, assetsDir: 'static', load: async () => ({ render: () => new Response('document') }), assets: async () => ({ css: '', body: '', preloads: '' }) }) - : createAssetNotFound({ routing, assetsDir: 'static' })); + : createAssetNotFound({ routing, assetsDir: 'static' }))); + if (mode === 'pages') { + app.use('/rewrite-miss', async (c, next) => { + c.res.headers.set('X-Previous-Header', 'present'); + await next(); + c.header('X-Route-Probe', 'configured'); + }); + app.get('/rewrite-miss', (c) => c.rewrite('/api/no-route/missing')); + app.use('/api/files/clone.json', async (c, next) => { + await next(); + c.header('X-Route-Probe', 'configured'); + c.res = c.res.clone(); + }); + app.use('/api/files/probe.json', async (c, next) => { + await c.rewrite('/api/probe'); + await next(); + }); + app.use('/api/files/hidden.json', async (c, next) => { + await next(); + if (c.res.status === 404) { + c.res = c.text('Hidden', 404); + } + }); + app.get('/api/:name', (c) => c.json({ error: 'missing' }, 404)); + } apps[mode] = app; } const outer = createAssetFallback({ assetsDir: 'static' }); @@ -214,6 +248,9 @@ beforeAll(async () => { return Response.json({ immutable, originalContentType: original.headers.get('content-type'), resultContentType: result.headers.get('content-type'), originalPolicy: original.headers.get('cache-control'), resultPolicy: result.headers.get('cache-control'), body: [...new Uint8Array(await result.arrayBuffer())] }); } const mode = url.searchParams.get('mode') ?? 'pages'; + if (mode === 'native') { + return outer(request, env, await apps.pages.fetch(request, env, context)); + } return mode === 'outer' ? outer(request, env, new Response('not found', { status: 404 })) : apps[mode].fetch(request, env, context); @@ -745,3 +782,25 @@ describe('native output: "static" pages in real Workers', () => { await response.arrayBuffer(); }); }); + +it.each(['GET', 'HEAD'])( + 'preserves intentional 404s and serves real asset misses for %s', + async (method) => { + const cases = [ + ['/api/data.json', 404, '{"error":"missing"}'], + ['/api/files/hidden.json', 404, 'Hidden'], + ['/api/files/open.json', 200, '{"static":"api miss"}'], + ['/api/files/probe.json', 200, '{"static":"probe"}'], + ['/api/files/clone.json', 200, '{"static":"clone"}'], + ['/rewrite-miss', 200, 'rewrite asset'], + ['/files/open.json', 200, '{"static":"open"}'], + ] as const; + for (const [path, status, body] of cases) { + const response = await runtime!.dispatchFetch('https://app.test' + path + '?mode=native', { + method, + }); + expect(response.status, path).toBe(status); + expect(await response.text(), path).toBe(method === 'GET' ? body : ''); + } + }, +); diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 219c7f521..c73eaaf57 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -102,7 +102,7 @@ describe('compileRouteTable', () => { testOptions, ); - expect(code).toContain('app.notFound(__createSsrFallback('); + expect(code).toContain('app.notFound(__routingRuntime.trackNotFound(__createSsrFallback('); expect(code).toContain('load: () => import("/src/main.ssr.ts")'); expect(code).toContain('"entryId":"src/main.client.tsx"'); expect(code).toContain('"reactRefresh":true'); @@ -210,7 +210,7 @@ describe('compileRouteTable', () => { withPages.indexOf('app.get("/chat/:room", __createPageGetHandler'), ); expect(withPages.indexOf(fallback)).toBeLessThan( - withPages.indexOf('app.notFound(__createPagesFallback'), + withPages.indexOf('app.notFound(__routingRuntime.trackNotFound(__createPagesFallback'), ); }); @@ -806,7 +806,7 @@ describe('default-target dev parity', () => { fallbackRules, }, ); - expect(code).toContain('app.notFound(__createSsrFallback('); + expect(code).toContain('app.notFound(__routingRuntime.trackNotFound(__createSsrFallback('); expect(code).toContain('nodeTarget: false'); expect(code).toContain('"source":"/docs/*"'); }); @@ -1490,7 +1490,7 @@ describe('typed runtime metadata wiring', () => { it('looks rewrite destinations up in static assets', () => { const rewrite = [{ source: '/', destination: '/index.html', status: 200, force: true }]; expect(compile({ dev: false, redirectRules: rewrite })).toContain( - 'app.notFound(__createAssetNotFound', + 'app.notFound(__routingRuntime.trackNotFound(__createAssetNotFound', ); }); it('wires fetch validation, opaque internal routes and optional ISR through runtime callbacks', () => { diff --git a/packages/void/test/unit/pages-compile.test.ts b/packages/void/test/unit/pages-compile.test.ts index 9ad59a0a4..05eabccba 100644 --- a/packages/void/test/unit/pages-compile.test.ts +++ b/packages/void/test/unit/pages-compile.test.ts @@ -120,7 +120,7 @@ describe('Pages metadata wiring', () => { '__createErrorRenderer({"dev":true,"projectRoot":"/project","homeDir":"/Users/test"})', ); expect(code).toContain('app.onError(__errorRenderer.onError)'); - expect(code).toContain('app.notFound(__createPagesFallback('); + expect(code).toContain('app.notFound(__routingRuntime.trackNotFound(__createPagesFallback('); expect(code).not.toContain('function __voidRenderDevErrorOverlay'); }); it('keeps layout CSS roots in cascade order and includes island markdown metadata', () => { From ce581556df94f55085b6dda3817636cf8d2940c9 Mon Sep 17 00:00:00 2001 From: Christoph Nakazawa Date: Wed, 11 Nov 2026 10:02:09 +0900 Subject: [PATCH 2/2] Preserve route misses through response compression. --- packages/void/src/runtime/routing.ts | 36 +++++++++++- .../native-asset-cache-policy.test.ts | 56 +++++++++++++++++-- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 25c12112d..448c8ea0f 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -82,6 +82,37 @@ export function createRoutingRuntime( let trackAssetMisses = false; const intentionalResponses = new WeakSet(); const routeMisses = new WeakMap>(); + const missBodies = new WeakMap>(); + const trackMissBody = (c: Context, body: ReadableStream | null): void => { + if (!body) { + return; + } + let bodies = missBodies.get(c); + if (!bodies) { + bodies = new WeakSet(); + missBodies.set(c, bodies); + } + if (bodies.has(body)) { + return; + } + bodies.add(body); + // Middleware such as Hono's compress() replaces the body with a transform + // of this stream. That still carries a miss; an unrelated replacement does not. + if (Object.isExtensible(body)) { + const pipeThrough = body.pipeThrough; + Object.defineProperty(body, 'pipeThrough', { + configurable: true, + writable: true, + value(this: ReadableStream, ...args: Parameters) { + const transformed = pipeThrough.apply(this, args); + if (this === body) { + trackMissBody(c, transformed); + } + return transformed; + }, + }); + } + }; const recordMiss = (c: Context, response: Response): void => { const misses = routeMisses.get(c) ?? []; if (misses.includes(response)) { @@ -89,6 +120,7 @@ export function createRoutingRuntime( } misses.push(response); routeMisses.set(c, misses); + trackMissBody(c, response.body); trackResponseCopies(c); // Fetch's clone tees the body, so neither clone shares its former stream. if (Object.isExtensible(response)) { @@ -99,6 +131,7 @@ export function createRoutingRuntime( value(this: Response) { const copy = clone.call(this); if (this === response) { + trackMissBody(c, this.body); recordMiss(c, copy); } return copy; @@ -110,7 +143,8 @@ export function createRoutingRuntime( routeMisses .get(c) ?.some((miss) => miss === response || (miss.body !== null && miss.body === response.body)) === - true; + true || + (response.body !== null && missBodies.get(c)?.has(response.body) === true); const tracksResponseCopies = new WeakSet(); const trackResponseCopies = (c: Context): void => { if (tracksResponseCopies.has(c)) { diff --git a/packages/void/test/integration/native-asset-cache-policy.test.ts b/packages/void/test/integration/native-asset-cache-policy.test.ts index 567e89ceb..72b8339f5 100644 --- a/packages/void/test/integration/native-asset-cache-policy.test.ts +++ b/packages/void/test/integration/native-asset-cache-policy.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gunzipSync } from 'node:zlib'; import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; import { build, type Manifest } from 'vite'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; @@ -169,6 +170,9 @@ beforeAll(async () => { writeFileSync(join(assets, 'api/files/hidden.json'), '{"static":"hidden"}'); writeFileSync(join(assets, 'api/files/probe.json'), '{"static":"probe"}'); writeFileSync(join(assets, 'api/files/clone.json'), '{"static":"clone"}'); + writeFileSync(join(assets, 'api/files/compressed.json'), '{"static":"compressed"}'); + writeFileSync(join(assets, 'api/files/clone-original.json'), '{"static":"clone original"}'); + writeFileSync(join(assets, 'api/files/pipe.json'), '{"static":"pipe"}'); writeFileSync(join(assets, 'rewrite-miss'), 'rewrite asset'); mkdirSync(join(assets, 'files')); writeFileSync(join(assets, 'files/open.json'), '{"static":"open"}'); @@ -188,12 +192,14 @@ beforeAll(async () => { entry, ` import { Hono } from ${JSON.stringify(fileURLToPath(import.meta.resolve('hono')))}; + import { compress } from ${JSON.stringify(fileURLToPath(import.meta.resolve('hono/compress')))}; import { createPagesFallback, createSsrFallback, createAssetNotFound } from ${JSON.stringify(runtimeSource('routing-pages.ts'))}; import { applyAssetCachePolicy, createAssetFallback } from ${JSON.stringify(runtimeSource('routing-worker.ts'))}; import { createRoutingRuntime } from ${JSON.stringify(runtimeSource('routing.ts'))}; import { createErrorRenderer } from ${JSON.stringify(runtimeSource('routing-errors.ts'))}; const apps = {}; - for (const mode of ['pages', 'ssr', 'static', 'headers']) { + for (const mode of ['pages', 'ssr', 'static', 'headers', 'compressed']) { + const pages = mode === 'pages' || mode === 'compressed'; const app = new Hono(); const routing = createRoutingRuntime(app, {}); app.use(routing.contextMiddleware); @@ -204,12 +210,21 @@ beforeAll(async () => { ] }])); app.get('/health', (c) => c.text('healthy', 200, { 'cache-control': 'private, no-store' })); } - app.notFound(routing.trackNotFound(mode === 'pages' + app.notFound(routing.trackNotFound(pages ? createPagesFallback({ routing, assetsDir: 'static', errors: createErrorRenderer({ projectRoot: '/fixture', homeDir: '/home' }) }) : mode === 'ssr' ? createSsrFallback({ routing, assetsDir: 'static', load: async () => ({ render: () => new Response('document') }), assets: async () => ({ css: '', body: '', preloads: '' }) }) : createAssetNotFound({ routing, assetsDir: 'static' }))); - if (mode === 'pages') { + if (pages) { + if (mode === 'compressed') app.use(compress()); + app.use('/api/files/clone-original.json', async (c, next) => { + await next(); + await c.res.clone().arrayBuffer(); + }); + app.use('/api/files/pipe.json', async (c, next) => { + await next(); + c.res = new Response(c.res.body.pipeThrough(new TransformStream()), c.res); + }); app.use('/rewrite-miss', async (c, next) => { c.res.headers.set('X-Previous-Header', 'present'); await next(); @@ -248,8 +263,8 @@ beforeAll(async () => { return Response.json({ immutable, originalContentType: original.headers.get('content-type'), resultContentType: result.headers.get('content-type'), originalPolicy: original.headers.get('cache-control'), resultPolicy: result.headers.get('cache-control'), body: [...new Uint8Array(await result.arrayBuffer())] }); } const mode = url.searchParams.get('mode') ?? 'pages'; - if (mode === 'native') { - return outer(request, env, await apps.pages.fetch(request, env, context)); + if (mode === 'native' || mode === 'compressed') { + return outer(request, env, await apps[mode === 'native' ? 'pages' : mode].fetch(request, env, context)); } return mode === 'outer' ? outer(request, env, new Response('not found', { status: 404 })) @@ -804,3 +819,34 @@ it.each(['GET', 'HEAD'])( } }, ); + +it('keeps asset misses and intentional 404s distinct through compression', async () => { + const cases = [ + ['/api/files/compressed.json', 200, '{"static":"compressed"}'], + ['/api/files/clone.json', 200, '{"static":"clone"}'], + ['/api/files/clone-original.json', 200, '{"static":"clone original"}'], + ['/api/files/pipe.json', 200, '{"static":"pipe"}'], + ['/api/files/hidden.json', 404, 'Hidden'], + ['/api/data.json', 404, '{"error":"missing"}'], + ] as const; + for (const method of ['GET', 'HEAD']) { + for (const [path, status, body] of cases) { + const response = await runtime!.dispatchFetch( + 'https://app.test' + path + '?mode=compressed', + { + method, + headers: { 'Accept-Encoding': 'gzip' }, + }, + ); + expect(response.status, `${method} ${path}`).toBe(status); + const bytes = new Uint8Array(await response.arrayBuffer()); + + // Intentional 404s keep the compressed body; successful fallbacks replace it with the asset. + const text = + method === 'GET' && status === 404 + ? gunzipSync(bytes).toString() + : new TextDecoder().decode(bytes); + expect(text, `${method} ${path}`).toBe(method === 'GET' ? body : ''); + } + } +});