Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/server/handlers/request/prod-hydration-module.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,6 +55,33 @@ function makeCtx(overrides: Partial<HandlerContext> = {}): 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();
Expand Down Expand Up @@ -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");
});
});
14 changes: 12 additions & 2 deletions src/server/handlers/request/prod-hydration-module.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down