162 lines
7.3 KiB
JavaScript
162 lines
7.3 KiB
JavaScript
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<!-- next release -->\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 });
|
|
}
|