import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createDeploymentHttpServer, listenDeploymentHttpServer, loadDeploymentContract, } from "./deployment-http-server.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const sourceDist = path.join(repoRoot, "web/dist"); const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-deployment-cache-")); const root = path.join(workspace, "dist"); const contract = loadDeploymentContract(); let server; function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } function route(id) { const value = contract.responseHeaders.routes.find((candidate) => candidate.id === id); assert.ok(value, `deployment contract route is missing: ${id}`); return value; } async function responseBytes(response) { return Buffer.from(await response.arrayBuffer()); } async function expectCache(origin, url, expected) { const response = await fetch(`${origin}${url}`, { cache: "no-store" }); assert.equal(response.status, 200, `cache probe failed url=${url} status=${response.status}`); assert.equal( response.headers.get("cache-control"), expected, `Cache-Control mismatch url=${url}`, ); assert.match(response.headers.get("etag"), /^"sha256-[a-f0-9]{64}"$/); return response; } function contentHashedAssets() { const assetRoot = path.join(root, "assets"); const names = fs.readdirSync(assetRoot).sort(); const cases = [ ["entry JS", names.filter((name) => name.endsWith(".js") && !name.includes(".worker-"))], ["CSS", names.filter((name) => name.endsWith(".css"))], ["Worker", names.filter((name) => name.includes(".worker-") && name.endsWith(".js"))], ]; for (const [role, roleNames] of cases) { assert.ok(roleNames.length > 0, `production build has no content-hashed ${role} asset`); for (const name of roleNames) { assert.match(name, /-[A-Za-z0-9_-]{8}\.(?:js|css)$/, `${role} asset is not content hashed: ${name}`); } } return cases.flatMap(([, namesForRole]) => namesForRole).map((name) => `/assets/${name}`); } try { assert.ok(fs.statSync(path.join(sourceDist, "index.html")).size > 0, "web/dist is missing; run the production build first"); fs.cpSync(sourceDist, root, { recursive: true }); const manifestPath = path.join(root, "engine-manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); assert.equal(manifest.schemaVersion, 2); assert.match(manifest.releaseId, /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/); server = createDeploymentHttpServer({ root, contract }); const origin = await listenDeploymentHttpServer(server); const noCache = route("entry-document").cacheControl; const manifestNoCache = route("runtime-manifest").cacheControl; const immutable = route("content-hashed-assets").cacheControl; const engineNoCache = route("stable-engine-assets").cacheControl; const entry = await expectCache(origin, "/", noCache); const entryEtag = entry.headers.get("etag"); assert.deepEqual(await responseBytes(entry), fs.readFileSync(path.join(root, "index.html"))); await expectCache(origin, "/index.html", noCache); const unchangedEntry = await fetch(`${origin}/`, { headers: { "If-None-Match": entryEtag } }); assert.equal(unchangedEntry.status, 304); assert.equal(unchangedEntry.headers.get("cache-control"), noCache); const nextEntryBytes = Buffer.concat([ fs.readFileSync(path.join(root, "index.html")), Buffer.from("\n\n"), ]); fs.writeFileSync(path.join(root, "index.html"), nextEntryBytes); const changedEntry = await fetch(`${origin}/`, { headers: { "If-None-Match": entryEtag } }); assert.equal(changedEntry.status, 200); assert.equal(changedEntry.headers.get("cache-control"), noCache); assert.notEqual(changedEntry.headers.get("etag"), entryEtag); assert.deepEqual(await responseBytes(changedEntry), nextEntryBytes); const firstManifest = await expectCache(origin, "/engine-manifest.json", manifestNoCache); const firstManifestEtag = firstManifest.headers.get("etag"); assert.deepEqual(await responseBytes(firstManifest), fs.readFileSync(manifestPath)); const unchangedManifest = await fetch(`${origin}/engine-manifest.json`, { headers: { "If-None-Match": firstManifestEtag }, }); assert.equal(unchangedManifest.status, 304); assert.equal(unchangedManifest.headers.get("cache-control"), manifestNoCache); assert.equal((await unchangedManifest.arrayBuffer()).byteLength, 0); const assetUrls = contentHashedAssets(); for (const url of assetUrls) { const response = await expectCache(origin, url, immutable); const bytes = await responseBytes(response); assert.equal(response.headers.get("etag"), `"sha256-${sha256(bytes)}"`); } const uniqueResources = [...new Map( manifest.variants.flatMap((variant) => Object.values(variant.resources)) .map((resource) => [resource.url, resource]), ).values()]; for (const resource of uniqueResources) { const response = await expectCache(origin, resource.url, engineNoCache); assert.equal(sha256(await responseBytes(response)), resource.sha256); } const nextManifest = { ...manifest, releaseId: `${manifest.releaseId}.next` }; fs.writeFileSync(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`); const changedManifest = await fetch(`${origin}/engine-manifest.json`, { headers: { "If-None-Match": firstManifestEtag }, }); assert.equal(changedManifest.status, 200); assert.equal(changedManifest.headers.get("cache-control"), manifestNoCache); assert.notEqual(changedManifest.headers.get("etag"), firstManifestEtag); assert.equal((await changedManifest.json()).releaseId, nextManifest.releaseId); const stableResource = uniqueResources.find((resource) => resource.url.endsWith(".js")); assert.ok(stableResource, "engine manifest has no stable JS resource"); const stablePath = path.join(root, stableResource.url.replace(/^\/+/, "")); const stableFirst = await fetch(`${origin}${stableResource.url}`, { cache: "no-store" }); const stableFirstEtag = stableFirst.headers.get("etag"); const stableUnchanged = await fetch(`${origin}${stableResource.url}`, { headers: { "If-None-Match": stableFirstEtag }, }); assert.equal(stableUnchanged.status, 304); assert.equal(stableUnchanged.headers.get("cache-control"), engineNoCache); const replacement = Buffer.from("export default () => { throw new Error('next release'); };\n"); fs.writeFileSync(stablePath, replacement); const stableChanged = await fetch(`${origin}${stableResource.url}`, { headers: { "If-None-Match": stableFirstEtag }, }); assert.equal(stableChanged.status, 200); assert.equal(stableChanged.headers.get("cache-control"), engineNoCache); assert.notEqual(stableChanged.headers.get("etag"), stableFirstEtag); assert.equal(sha256(await responseBytes(stableChanged)), sha256(replacement)); assert.notEqual(sha256(replacement), stableResource.sha256); process.stdout.write( `deployment-cache-ok noCache=3 immutable=${assetUrls.length} stableEngine=${uniqueResources.length} revalidation=304/200\n`, ); } finally { if (server?.listening) await new Promise((resolve) => server.close(resolve)); fs.rmSync(workspace, { recursive: true, force: true }); }