diff --git a/src/server/handlers/request/prod-hydration-module.handler.test.ts b/src/server/handlers/request/prod-hydration-module.handler.test.ts index 0c7197183b..70d466d062 100644 --- a/src/server/handlers/request/prod-hydration-module.handler.test.ts +++ b/src/server/handlers/request/prod-hydration-module.handler.test.ts @@ -2,8 +2,10 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { ProdHydrationModuleHandler } from "./prod-hydration-module.handler.ts"; +import { StaticHandler } from "./static.handler.ts"; import type { HandlerContext } from "../types.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { RouteRegistry } from "#veryfront/routing/registry/index.ts"; import { getProdHydrationModulePath, PROD_HYDRATION_MODULE_PATH, @@ -53,6 +55,33 @@ function makeCtx(overrides: Partial = {}): HandlerContext { }; } +function staleVersionedPath(): string { + const currentPath = getProdHydrationModulePath(); + const currentHash = currentPath.match(/hydration-runtime\.([0-9a-f]{8})\.js$/)?.[1]; + assertExists(currentHash); + const staleHash = currentHash === "00000000" ? "11111111" : "00000000"; + return `/_veryfront/hydration-runtime.${staleHash}.js`; +} + +function makeStaticHandler(content: string | null): StaticHandler { + const handler = new StaticHandler(); + (handler as any).staticService = { + resolveFile: (pathname: string) => + Promise.resolve( + content === null ? null : { + path: `/tmp/test-project/dist${pathname}`, + data: new TextEncoder().encode(content), + etag: '"release-runtime"', + contentType: "application/javascript; charset=utf-8", + cacheStrategy: "immutable", + source: "dist", + }, + ), + isAssetRequest: () => true, + }; + return handler; +} + describe("server/handlers/request/prod-hydration-module.handler", () => { it("serves the versioned production hydration runtime module with immutable caching", async () => { const handler = new ProdHydrationModuleHandler(); @@ -112,4 +141,84 @@ describe("server/handlers/request/prod-hydration-module.handler", () => { assertEquals(second.response?.headers.get("pragma"), null); assertEquals(second.response?.headers.get("expires"), null); }); + + it("lets a non-current versioned path fall through to release static assets", async () => { + const handler = new ProdHydrationModuleHandler(); + const result = await handler.handle( + new Request(`http://localhost${staleVersionedPath()}`), + makeCtx(), + ); + + assertEquals(result.continue, true); + assertEquals(result.response, undefined); + }); + + it("serves a release-baked versioned runtime through the handler chain", async () => { + const releasePath = staleVersionedPath(); + const registry = new RouteRegistry() + .register(new ProdHydrationModuleHandler()) + .register(makeStaticHandler("export const releaseRuntime = true;")); + const response = await registry.execute( + new Request(`http://localhost${releasePath}`), + makeCtx(), + ); + + assertExists(response); + assertEquals(response.status, 200); + assertEquals(response.headers.get("cache-control"), IMMUTABLE_CACHE_CONTROL); + assertEquals(await response.text(), "export const releaseRuntime = true;"); + }); + + it("returns a non-cacheable 404 after a versioned release asset misses", async () => { + const registry = new RouteRegistry() + .register(new ProdHydrationModuleHandler()) + .register(makeStaticHandler(null)); + const response = await registry.execute( + new Request(`http://localhost${staleVersionedPath()}`), + makeCtx(), + ); + + assertExists(response); + assertEquals(response.status, 404); + assertEquals(response.headers.get("cache-control"), NO_CACHE_CONTROL); + assertEquals(await response.text(), "Not Found"); + }); + + it("does not answer 304 for a release runtime when the current ETag matches", async () => { + const currentHandler = new ProdHydrationModuleHandler(); + const current = await currentHandler.handle( + new Request(`http://localhost${getProdHydrationModulePath()}`), + makeCtx(), + ); + const etag = current.response?.headers.get("etag"); + assertExists(etag); + + const registry = new RouteRegistry() + .register(currentHandler) + .register(makeStaticHandler("export const releaseRuntime = true;")); + const response = await registry.execute( + new Request(`http://localhost${staleVersionedPath()}`, { + headers: { "if-none-match": etag }, + }), + makeCtx(), + ); + + assertExists(response); + assertEquals(response.status, 200); + assertEquals(await response.text(), "export const releaseRuntime = true;"); + }); + + it("serves a release-baked runtime on HEAD requests without a body", async () => { + const registry = new RouteRegistry() + .register(new ProdHydrationModuleHandler()) + .register(makeStaticHandler("export const releaseRuntime = true;")); + const response = await registry.execute( + new Request(`http://localhost${staleVersionedPath()}`, { method: "HEAD" }), + makeCtx(), + ); + + assertExists(response); + assertEquals(response.status, 200); + assertEquals(await response.text(), "", "HEAD responses carry no body"); + }); }); diff --git a/src/server/handlers/request/prod-hydration-module.handler.ts b/src/server/handlers/request/prod-hydration-module.handler.ts index 6706054d15..4459b84ac4 100644 --- a/src/server/handlers/request/prod-hydration-module.handler.ts +++ b/src/server/handlers/request/prod-hydration-module.handler.ts @@ -2,6 +2,7 @@ import { BaseHandler } from "../response/base.ts"; import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } from "../types.ts"; import { generateProdHydrationModule, + getProdHydrationModulePath, isVersionedProdHydrationModulePath, PROD_HYDRATION_MODULE_PATH, PROD_HYDRATION_MODULE_VERSIONED_PATH_PATTERN, @@ -39,9 +40,18 @@ export class ProdHydrationModuleHandler extends BaseHandler { return this.continue(); } - const method = req.method.toUpperCase(); const pathname = new URL(req.url).pathname; - const cacheStrategy = isVersionedProdHydrationModulePath(pathname) ? "immutable" : "no-cache"; + const isVersioned = isVersionedProdHydrationModulePath(pathname); + + // Serve only the current content address dynamically. Non-current hashes + // may name valid release-baked assets, so StaticHandler owns their lookup + // and returns the final non-cacheable 404 when no stored asset matches. + if (isVersioned && pathname !== getProdHydrationModulePath()) { + return this.continue(); + } + + const method = req.method.toUpperCase(); + const cacheStrategy = isVersioned ? "immutable" : "no-cache"; const { js, etag } = getProdHydrationModuleBundle(); const builder = this.createResponseBuilder(ctx).withCORS(req, ctx.securityConfig?.cors);