Advance M7 workflows and release operations
This commit is contained in:
161
tools/web/check-deployment-cache.mjs
Normal file
161
tools/web/check-deployment-cache.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user