feat(demo): demo.pcbjam.com deploy — versioned WASM CDN + static gallery + tag CI
Cross-origin WASM CDN (cdn.pcbjam.com, R2): per-tool content-addressed, immutable folders + a per-release runtime manifest (snapshot from registry). boot.ts loads pthread workers cross-origin via a same-origin blob shim. Static no-backend project source (demo gallery; Save downloads to local), api.uploadFileBytes kept and config-gated. demo.pcbjam.com on Cloudflare Pages. deploy-demo.yml (tag v*) snapshots WASM + content + builds + deploys; publish-wasm.yml builds on Ubicloud. Design/spec docs live in pcbjam-private. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5ff9f1c7b8
commit
5aae2a0d16
23 changed files with 1489 additions and 112 deletions
97
scripts/deploy/build-demo.mjs
Normal file
97
scripts/deploy/build-demo.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env node
|
||||
// Build the GPL standalone for the no-backend demo (demo.pcbjam.com): pin it to
|
||||
// the CDN WASM root + this tag's manifests + the static gallery, run the vite
|
||||
// build, and drop the Cloudflare Pages _headers/_redirects into dist/. The
|
||||
// resulting pcbjam/web/standalone/dist/ is what `wrangler pages deploy` ships.
|
||||
// See docs/features/demo-deploy/ (P4).
|
||||
//
|
||||
// node scripts/build-demo.mjs --tag 2.7.7 [--cdn https://cdn.pcbjam.com]
|
||||
//
|
||||
// WASM + example bytes come from the CDN at runtime, so the local public/wasm
|
||||
// symlink (dev only) is kept OUT of the bundle (temporarily moved aside during
|
||||
// the build; in CI it doesn't exist at all).
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = { tag: null, cdn: "https://cdn.pcbjam.com" };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const next = () => argv[++i];
|
||||
switch (argv[i]) {
|
||||
case "--tag": a.tag = next(); break;
|
||||
case "--cdn": a.cdn = next(); break;
|
||||
default: throw new Error(`unknown arg: ${argv[i]}`);
|
||||
}
|
||||
}
|
||||
if (!a.tag) throw new Error("--tag <release tag> is required");
|
||||
a.cdn = a.cdn.replace(/\/+$/, "");
|
||||
return a;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const a = parseArgs(process.argv);
|
||||
const repoRoot = resolve(process.cwd());
|
||||
const standalone = join(repoRoot, "web/standalone");
|
||||
const dist = join(standalone, "dist");
|
||||
const publicWasm = join(standalone, "public/wasm");
|
||||
const stash = join(standalone, "public/.wasm.demo-stashed");
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
// Versioned CDN: each tool resolves to wasm/<tool>/<ver>/ via this manifest.
|
||||
VITE_WASM_ROOT: `${a.cdn}/wasm`,
|
||||
VITE_WASM_MANIFEST: `manifest-${a.tag}.json`,
|
||||
// Read-only example gallery, saves download to local.
|
||||
VITE_PROJECT_SOURCE: "static",
|
||||
VITE_PROJECT_MANIFEST_URL: `${a.cdn}/content/${a.tag}/manifest.json`,
|
||||
// Built-in offline symbols (no backend); cross-tab collab only.
|
||||
VITE_LIBS_SOURCE: "static",
|
||||
VITE_YJS_PROVIDER: "broadcastchannel",
|
||||
};
|
||||
|
||||
console.log(`build-demo: tag=${a.tag} cdn=${a.cdn}`);
|
||||
console.log(` VITE_WASM_ROOT=${env.VITE_WASM_ROOT}`);
|
||||
console.log(` VITE_WASM_MANIFEST=${env.VITE_WASM_MANIFEST}`);
|
||||
console.log(` VITE_PROJECT_MANIFEST_URL=${env.VITE_PROJECT_MANIFEST_URL}`);
|
||||
|
||||
// Keep the dev-only WASM symlink out of the bundle (it'd copy 100s of MB into
|
||||
// dist/; the CDN serves it). In CI it isn't present, so this is a no-op there.
|
||||
const hadWasm = existsSync(publicWasm) || isSymlink(publicWasm);
|
||||
if (hadWasm) renameSync(publicWasm, stash);
|
||||
try {
|
||||
execFileSync(
|
||||
"pnpm",
|
||||
["--dir", "web", "--filter", "@pcbjam/standalone", "build"],
|
||||
{ cwd: repoRoot, env, stdio: "inherit" },
|
||||
);
|
||||
} finally {
|
||||
if (hadWasm) renameSync(stash, publicWasm);
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: never ship local wasm even if a copy slipped through.
|
||||
rmSync(join(dist, "wasm"), { recursive: true, force: true });
|
||||
|
||||
for (const f of ["_headers", "_redirects"]) {
|
||||
copyFileSync(join(repoRoot, "deploy/demo", f), join(dist, f));
|
||||
}
|
||||
|
||||
console.log(`done → ${dist} (ready for: wrangler pages deploy)`);
|
||||
}
|
||||
|
||||
function isSymlink(p) {
|
||||
try {
|
||||
return lstatSync(p).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
136
scripts/deploy/lib/cdn-store.mjs
Normal file
136
scripts/deploy/lib/cdn-store.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Shared CDN publish primitives for the demo-deploy pipeline (publish-wasm,
|
||||
// publish-content). A pluggable object store with two drivers — `local` (writes
|
||||
// the exact bucket layout to a dir + a `_uploads.json` HTTP-metadata sidecar, so
|
||||
// publishing is verifiable offline) and `r2` (shells `wrangler r2 object
|
||||
// {get,put}`, needing only CLOUDFLARE_API_TOKEN). See docs/features/demo-deploy/.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { gzipSync, brotliCompressSync, constants as zc } from "node:zlib";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
export const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
export const NO_STORE = "no-store";
|
||||
|
||||
export const sha256hex = (buf) => createHash("sha256").update(buf).digest("hex");
|
||||
|
||||
/** Compress bytes; returns { bytes, encoding }. mode: "gzip" | "br" | "none". */
|
||||
export function compressBytes(bytes, mode, quality) {
|
||||
if (mode === "none") return { bytes, encoding: null };
|
||||
if (mode === "gzip")
|
||||
return { bytes: gzipSync(bytes, { level: quality ?? 6 }), encoding: "gzip" };
|
||||
// brotli — default quality 5 (good ratio, far faster than 11 on 100s of MB).
|
||||
return {
|
||||
bytes: brotliCompressSync(bytes, {
|
||||
params: { [zc.BROTLI_PARAM_QUALITY]: quality ?? 5 },
|
||||
}),
|
||||
encoding: "br",
|
||||
};
|
||||
}
|
||||
|
||||
/** Content-Type from a file extension (best-effort; covers KiCad + web assets). */
|
||||
export function contentTypeForPath(name) {
|
||||
if (name.endsWith(".wasm")) return "application/wasm";
|
||||
if (name.endsWith(".js") || name.endsWith(".mjs")) return "text/javascript";
|
||||
if (name.endsWith(".json")) return "application/json";
|
||||
if (name.endsWith(".html")) return "text/html";
|
||||
if (name.endsWith(".svg")) return "image/svg+xml";
|
||||
if (name.endsWith(".png")) return "image/png";
|
||||
// KiCad project/board/schematic/lib files are s-expr / ini text.
|
||||
if (/\.(kicad_\w+|net|csv|pos|drl|gbr|g[a-z0-9]+)$/i.test(name))
|
||||
return "text/plain; charset=utf-8";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
// --- drivers ------------------------------------------------------------------
|
||||
// Interface:
|
||||
// getJSON(key) -> object | null (object stored uncompressed)
|
||||
// put(key, bytes, { contentType, contentEncoding, cacheControl })
|
||||
|
||||
export function localDriver(outDir) {
|
||||
const uploadsPath = join(outDir, "_uploads.json");
|
||||
const uploads = existsSync(uploadsPath)
|
||||
? JSON.parse(readFileSync(uploadsPath, "utf8"))
|
||||
: {};
|
||||
const abs = (key) => join(outDir, key);
|
||||
return {
|
||||
kind: "local",
|
||||
getJSON(key) {
|
||||
const p = abs(key);
|
||||
return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null;
|
||||
},
|
||||
put(key, bytes, meta) {
|
||||
const p = abs(key);
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
writeFileSync(p, bytes);
|
||||
uploads[key] = { ...meta, bytes: bytes.length };
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(uploadsPath, JSON.stringify(uploads, null, 2));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function r2Driver({ bucket, remote }) {
|
||||
const wrangler = process.env.WRANGLER_CMD?.split(" ") || ["wrangler"];
|
||||
const flags = remote ? ["--remote"] : [];
|
||||
const run = (args) =>
|
||||
execFileSync(wrangler[0], [...wrangler.slice(1), ...args], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
maxBuffer: 1024 * 1024 * 512,
|
||||
});
|
||||
const tmp = join(tmpdir(), `r2put-${process.pid}`);
|
||||
return {
|
||||
kind: "r2",
|
||||
getJSON(key) {
|
||||
const dest = `${tmp}-get`;
|
||||
try {
|
||||
run(["r2", "object", "get", `${bucket}/${key}`, "--file", dest, ...flags]);
|
||||
} catch {
|
||||
return null; // not found / error → absent
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(dest, "utf8"));
|
||||
} finally {
|
||||
rmSync(dest, { force: true });
|
||||
}
|
||||
},
|
||||
put(key, bytes, meta) {
|
||||
mkdirSync(dirname(tmp), { recursive: true });
|
||||
writeFileSync(tmp, bytes);
|
||||
const args = [
|
||||
"r2", "object", "put", `${bucket}/${key}`,
|
||||
"--file", tmp,
|
||||
"--content-type", meta.contentType,
|
||||
"--cache-control", meta.cacheControl,
|
||||
...flags,
|
||||
];
|
||||
if (meta.contentEncoding) args.push("--content-encoding", meta.contentEncoding);
|
||||
run(args);
|
||||
rmSync(tmp, { force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Construct the driver named by `driver` ("local" | "r2"). */
|
||||
export function makeStore(driver, opts) {
|
||||
if (driver === "local") return localDriver(opts.out);
|
||||
if (driver === "r2") return r2Driver(opts);
|
||||
throw new Error(`unknown driver: ${driver}`);
|
||||
}
|
||||
|
||||
/** Put a JSON value (pretty-printed, uncompressed) with the given Cache-Control. */
|
||||
export function putJSON(store, key, obj, cacheControl) {
|
||||
store.put(key, Buffer.from(JSON.stringify(obj, null, 2)), {
|
||||
contentType: "application/json",
|
||||
contentEncoding: null,
|
||||
cacheControl,
|
||||
});
|
||||
}
|
||||
100
scripts/deploy/publish-content.mjs
Normal file
100
scripts/deploy/publish-content.mjs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env node
|
||||
// Publish the static demo "gallery" (example projects the no-backend standalone
|
||||
// opens read-only; Save downloads to local) to the CDN under content/<tag>/.
|
||||
// Implements the P3 part of docs/features/demo-deploy/.
|
||||
//
|
||||
// node scripts/publish-content.mjs --tag 2.7.7 --gallery content/gallery.json \
|
||||
// --driver local --out /tmp/cdn
|
||||
// node scripts/publish-content.mjs --tag 2.7.7 --driver r2 --bucket pcbjam-cdn --remote
|
||||
//
|
||||
// `content/gallery.json` CURATES the gallery by REFERENCING source files in the
|
||||
// repo (so we don't duplicate GPL KiCad data into the closed tree):
|
||||
// { "projects": [ { "slug","name","description","root","files":[...] } ] }
|
||||
// Each content/<tag>/ snapshot is immutable; the app pins
|
||||
// content/<tag>/manifest.json at build time (VITE_PROJECT_MANIFEST_URL).
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
contentTypeForPath,
|
||||
IMMUTABLE,
|
||||
makeStore,
|
||||
putJSON,
|
||||
} from "./lib/cdn-store.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = {
|
||||
tag: null,
|
||||
gallery: "deploy/demo/gallery.json",
|
||||
driver: "local",
|
||||
out: null,
|
||||
bucket: "pcbjam-cdn",
|
||||
remote: false,
|
||||
prefix: "content",
|
||||
builtAt: process.env.SOURCE_DATE || null,
|
||||
};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const next = () => argv[++i];
|
||||
switch (argv[i]) {
|
||||
case "--tag": a.tag = next(); break;
|
||||
case "--gallery": a.gallery = next(); break;
|
||||
case "--driver": a.driver = next(); break;
|
||||
case "--out": a.out = next(); break;
|
||||
case "--bucket": a.bucket = next(); break;
|
||||
case "--remote": a.remote = true; break;
|
||||
case "--prefix": a.prefix = next(); break;
|
||||
default: throw new Error(`unknown arg: ${argv[i]}`);
|
||||
}
|
||||
}
|
||||
if (!a.tag) throw new Error("--tag <release tag> is required");
|
||||
if (a.driver === "local" && !a.out) a.out = ".cdn-out";
|
||||
return a;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const a = parseArgs(process.argv);
|
||||
const builtAt = a.builtAt || new Date().toISOString();
|
||||
const store = makeStore(a.driver, a);
|
||||
const P = a.prefix;
|
||||
|
||||
const gallery = JSON.parse(readFileSync(a.gallery, "utf8"));
|
||||
console.log(
|
||||
`publish-content: tag=${a.tag} gallery=${a.gallery} driver=${store.kind} ` +
|
||||
`projects=${gallery.projects?.length ?? 0}`,
|
||||
);
|
||||
|
||||
const manifest = { schema: 1, tag: a.tag, builtAt, projects: [] };
|
||||
|
||||
for (const proj of gallery.projects ?? []) {
|
||||
if (!/^[a-z0-9][a-z0-9._-]*$/.test(proj.slug))
|
||||
throw new Error(`invalid project slug: ${proj.slug}`);
|
||||
const files = [];
|
||||
for (const rel of proj.files) {
|
||||
const srcPath = join(proj.root, rel);
|
||||
if (!existsSync(srcPath)) throw new Error(`missing source file: ${srcPath}`);
|
||||
const bytes = readFileSync(srcPath);
|
||||
// Files are served verbatim — the editor fetch()es the raw bytes. Text
|
||||
// KiCad files compress fine at the edge; we don't pre-encode them.
|
||||
store.put(`${P}/${a.tag}/${proj.slug}/${rel}`, bytes, {
|
||||
contentType: contentTypeForPath(rel),
|
||||
contentEncoding: null,
|
||||
cacheControl: IMMUTABLE,
|
||||
});
|
||||
files.push({ path: rel, size: bytes.length });
|
||||
}
|
||||
manifest.projects.push({
|
||||
slug: proj.slug,
|
||||
name: proj.name ?? proj.slug,
|
||||
description: proj.description ?? "",
|
||||
files,
|
||||
});
|
||||
console.log(` ${proj.slug}: ${files.length} file(s)`);
|
||||
}
|
||||
|
||||
// The snapshot is immutable; the app pins this exact URL at build time.
|
||||
putJSON(store, `${P}/${a.tag}/manifest.json`, manifest, IMMUTABLE);
|
||||
console.log(`done → ${P}/${a.tag}/manifest.json`);
|
||||
if (store.kind === "local") console.log(`local layout under: ${a.out}`);
|
||||
}
|
||||
|
||||
main();
|
||||
250
scripts/deploy/publish-wasm.mjs
Normal file
250
scripts/deploy/publish-wasm.mjs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
#!/usr/bin/env node
|
||||
// Publish the KiCad WASM artifacts to the versioned CDN (cdn.pcbjam.com, R2
|
||||
// `pcbjam-cdn`). Implements docs/features/demo-deploy/0001-wasm-cdn-versioning.md:
|
||||
// per-tool, content-addressed, self-contained folders + a per-release manifest
|
||||
// the standalone reads at runtime, with a `registry.json` for hash-dedupe.
|
||||
//
|
||||
// node scripts/publish-wasm.mjs --tag 2.7.7 --src pcbjam/output --driver local --out /tmp/cdn
|
||||
// node scripts/publish-wasm.mjs --tag 2.7.7 --src pcbjam/output --driver r2 --bucket pcbjam-cdn --remote
|
||||
//
|
||||
// Properties (see 0001): ONE atomic job; idempotent; content-addressed folders
|
||||
// are immutable; meta.json is written LAST as the completeness marker; an
|
||||
// unchanged tool is never re-uploaded; the build↔upload race is impossible.
|
||||
//
|
||||
// The `local` driver writes the exact bucket layout to --out (+ a sidecar
|
||||
// `_uploads.json` recording every object's HTTP metadata) so the whole thing is
|
||||
// verifiable offline. The `r2` driver shells `wrangler r2 object {get,put}` and
|
||||
// needs only CLOUDFLARE_API_TOKEN (+ CLOUDFLARE_ACCOUNT_ID).
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
compressBytes,
|
||||
IMMUTABLE,
|
||||
makeStore,
|
||||
NO_STORE,
|
||||
putJSON,
|
||||
sha256hex,
|
||||
} from "./lib/cdn-store.mjs";
|
||||
|
||||
// --- tools & per-file rules ---------------------------------------------------
|
||||
|
||||
// Tools served to the browser editor. sym_convert is a node CLI, not served.
|
||||
const TOOLS = [
|
||||
"pcbnew",
|
||||
"eeschema",
|
||||
"pl_editor",
|
||||
"symbol_editor",
|
||||
"footprint_editor",
|
||||
"gerbview",
|
||||
"calculator",
|
||||
];
|
||||
|
||||
// Files that make up a self-contained tool bundle. `<tool>` is substituted.
|
||||
const SHARED_FILES = ["wx.js", "wx-dom.js", "images.tar.gz"];
|
||||
const toolFiles = (tool) => [`${tool}.wasm`, `${tool}.js`, ...SHARED_FILES];
|
||||
|
||||
// Per-file HTTP rules (see the 0001 header matrix). `compress` is whether the
|
||||
// publisher compresses + sets Content-Encoding; images.tar.gz must stay RAW
|
||||
// gzip (KiCad gunzips it in JS) so it is octet-stream with NO encoding.
|
||||
function fileRule(name) {
|
||||
if (name.endsWith(".wasm"))
|
||||
return { contentType: "application/wasm", compress: true, cacheControl: IMMUTABLE };
|
||||
if (name.endsWith(".js"))
|
||||
return { contentType: "text/javascript", compress: true, cacheControl: IMMUTABLE };
|
||||
if (name === "images.tar.gz")
|
||||
return { contentType: "application/octet-stream", compress: false, cacheControl: IMMUTABLE };
|
||||
if (name.endsWith(".json"))
|
||||
return { contentType: "application/json", compress: false, cacheControl: IMMUTABLE };
|
||||
return { contentType: "application/octet-stream", compress: false, cacheControl: IMMUTABLE };
|
||||
}
|
||||
|
||||
// --- args ---------------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = {
|
||||
tag: null,
|
||||
src: "output",
|
||||
driver: "local",
|
||||
out: null,
|
||||
bucket: "pcbjam-cdn",
|
||||
remote: false,
|
||||
compress: "gzip", // gzip | br | none
|
||||
quality: null,
|
||||
tools: TOOLS,
|
||||
prefix: "wasm",
|
||||
builtAt: process.env.SOURCE_DATE || null,
|
||||
// Snapshot mode: write manifest-<tag>.json pinning the CURRENT registry
|
||||
// versions, with NO build/upload (the tag deploy reuses prebuilt WASM).
|
||||
fromRegistry: false,
|
||||
};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const k = argv[i];
|
||||
const next = () => argv[++i];
|
||||
switch (k) {
|
||||
case "--tag": a.tag = next(); break;
|
||||
case "--src": a.src = next(); break;
|
||||
case "--driver": a.driver = next(); break;
|
||||
case "--out": a.out = next(); break;
|
||||
case "--bucket": a.bucket = next(); break;
|
||||
case "--remote": a.remote = true; break;
|
||||
case "--compress": a.compress = next(); break;
|
||||
case "--quality": a.quality = Number(next()); break;
|
||||
case "--tools": a.tools = next().split(",").map((s) => s.trim()).filter(Boolean); break;
|
||||
case "--prefix": a.prefix = next(); break;
|
||||
case "--from-registry": a.fromRegistry = true; break;
|
||||
default: throw new Error(`unknown arg: ${k}`);
|
||||
}
|
||||
}
|
||||
if (!a.tag) throw new Error("--tag <release tag> is required");
|
||||
if (a.driver === "local" && !a.out) a.out = ".cdn-out";
|
||||
if (a.compress !== "gzip" && a.compress !== "br" && a.compress !== "none")
|
||||
throw new Error(`--compress must be gzip|br|none (got ${a.compress})`);
|
||||
return a;
|
||||
}
|
||||
|
||||
// --- tool identity ------------------------------------------------------------
|
||||
|
||||
// Identity of a tool = sha256 over the sorted (name: sha256(uncompressed bytes))
|
||||
// of its bundle files. Hash the SOURCE bytes, never the compressed upload, so
|
||||
// changing the compression level can never change a tool's version.
|
||||
function toolContentHash(files) {
|
||||
const lines = files
|
||||
.map((f) => `${f.name}:${sha256hex(f.bytes)}`)
|
||||
.sort();
|
||||
return "sha256:" + sha256hex(Buffer.from(lines.join("\n")));
|
||||
}
|
||||
|
||||
// --- publish ------------------------------------------------------------------
|
||||
|
||||
function gather(tool, srcDir) {
|
||||
return toolFiles(tool).map((name) => {
|
||||
const p = join(srcDir, name);
|
||||
if (!existsSync(p)) throw new Error(`missing artifact: ${p}`);
|
||||
return { name, bytes: readFileSync(p) };
|
||||
});
|
||||
}
|
||||
|
||||
function putFile(store, key, bytes, name, compress, quality) {
|
||||
const rule = fileRule(name);
|
||||
let body = bytes;
|
||||
let encoding = null;
|
||||
if (rule.compress && compress !== "none") {
|
||||
const c = compressBytes(bytes, compress, quality);
|
||||
body = c.bytes;
|
||||
encoding = c.encoding;
|
||||
}
|
||||
store.put(key, body, {
|
||||
contentType: rule.contentType,
|
||||
contentEncoding: encoding,
|
||||
cacheControl: rule.cacheControl,
|
||||
});
|
||||
return { name, raw: bytes.length, stored: body.length, encoding };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const a = parseArgs(process.argv);
|
||||
const builtAt = a.builtAt || new Date().toISOString();
|
||||
const store = makeStore(a.driver, a);
|
||||
const P = a.prefix;
|
||||
|
||||
const registry = store.getJSON(`${P}/registry.json`) || {
|
||||
schema: 1,
|
||||
tools: {},
|
||||
index: {},
|
||||
};
|
||||
registry.index ||= {};
|
||||
registry.tools ||= {};
|
||||
|
||||
const manifest = { schema: 1, tag: a.tag, builtAt, tools: {} };
|
||||
|
||||
// Snapshot mode (the tag deploy): pin manifest-<tag> to the CURRENT published
|
||||
// per-tool versions and STOP — no build, no upload. Honors "reuse prebuilt":
|
||||
// app releases that didn't change the WASM never re-touch the WASM blobs.
|
||||
if (a.fromRegistry) {
|
||||
for (const tool of a.tools) {
|
||||
const entry = registry.tools[tool];
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
`tool "${tool}" not in ${P}/registry.json — publish the WASM (full ` +
|
||||
`mode) before snapshotting a release manifest`,
|
||||
);
|
||||
manifest.tools[tool] = entry.version;
|
||||
}
|
||||
putJSON(store, `${P}/manifest-${a.tag}.json`, manifest, NO_STORE);
|
||||
console.log(
|
||||
`snapshot: manifest-${a.tag}.json ← registry (${a.tools.length} tools, no upload)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`publish-wasm: tag=${a.tag} src=${a.src} driver=${store.kind} compress=${a.compress}`,
|
||||
);
|
||||
|
||||
let uploaded = 0;
|
||||
let reused = 0;
|
||||
|
||||
for (const tool of a.tools) {
|
||||
const files = gather(tool, a.src);
|
||||
const hash = toolContentHash(files);
|
||||
const idx = (registry.index[tool] ||= {});
|
||||
|
||||
let ver = idx[hash];
|
||||
if (ver) {
|
||||
reused++;
|
||||
console.log(` ${tool}: reuse ${ver} (${hash.slice(0, 19)}…)`);
|
||||
} else {
|
||||
ver = a.tag;
|
||||
const metaKey = `${P}/${tool}/${ver}/meta.json`;
|
||||
const existing = store.getJSON(metaKey)?.hash ?? null;
|
||||
if (existing && existing !== hash) {
|
||||
throw new Error(
|
||||
`moved-tag guard: ${P}/${tool}/${ver}/ already holds ${existing} ` +
|
||||
`but this build is ${hash}. Re-tag with a NEW version, never ` +
|
||||
`overwrite an immutable folder.`,
|
||||
);
|
||||
}
|
||||
const sizes = [];
|
||||
for (const f of files) {
|
||||
const key = `${P}/${tool}/${ver}/${f.name}`;
|
||||
sizes.push(putFile(store, key, f.bytes, f.name, a.compress, a.quality));
|
||||
}
|
||||
// meta.json LAST — its presence marks the bundle complete.
|
||||
putJSON(
|
||||
store,
|
||||
metaKey,
|
||||
{
|
||||
tool,
|
||||
ver,
|
||||
hash,
|
||||
builtAt,
|
||||
files: Object.fromEntries(files.map((f) => [f.name, "sha256:" + sha256hex(f.bytes)])),
|
||||
},
|
||||
IMMUTABLE,
|
||||
);
|
||||
idx[hash] = ver;
|
||||
uploaded++;
|
||||
const tot = sizes.reduce((s, x) => s + x.stored, 0);
|
||||
console.log(
|
||||
` ${tool}: UPLOAD ${ver} (${hash.slice(0, 19)}…) ` +
|
||||
`${(tot / 1e6).toFixed(1)}MB stored`,
|
||||
);
|
||||
}
|
||||
registry.tools[tool] = { version: ver, hash };
|
||||
manifest.tools[tool] = ver;
|
||||
}
|
||||
|
||||
// Browser-facing manifest + convenience pointer, both uncached.
|
||||
putJSON(store, `${P}/manifest-${a.tag}.json`, manifest, NO_STORE);
|
||||
putJSON(store, `${P}/manifest-latest.json`, { tag: a.tag }, NO_STORE);
|
||||
// registry LAST, after every tool's meta.json exists.
|
||||
putJSON(store, `${P}/registry.json`, registry, NO_STORE);
|
||||
|
||||
console.log(
|
||||
`done: ${uploaded} uploaded, ${reused} reused → manifest-${a.tag}.json`,
|
||||
);
|
||||
if (store.kind === "local") console.log(`local layout under: ${a.out}`);
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Reference in a new issue