2026-06-18 17:09:40 +02:00
|
|
|
#!/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 ---------------------------------------------------
|
|
|
|
|
|
2026-07-02 14:48:11 +02:00
|
|
|
// Bundles served to the browser editor. The four editor tools (pcbnew, eeschema,
|
|
|
|
|
// footprint_editor, symbol_editor) are ALL served by the ONE merged kicad_editor
|
|
|
|
|
// bundle, booted with a runtime --frame flag (editor-unification Part 2) — none of
|
|
|
|
|
// them publishes anything of its own. The frontend maps tools onto bundles via
|
|
|
|
|
// TOOL_BUNDLE. sym_convert is a node CLI, not served.
|
feat(wasm): occ-split — lazy occ_service worker; kicad_editor drops OCC (−31%)
Move OpenCASCADE out of the merged editor image into occ_service: a separate
emscripten module (-sASYNCIFY=0, MODULARIZE, in-container -Oz finalize, 2N+8
pre-warmed pthread pool) booted lazily in a dedicated Web Worker on the first
STEP export or STEP/IGES model parse. kicad_editor.wasm ~190 MB -> 130 MB;
sessions that never touch OCC never fetch its 57 MB. STEP export works in the
browser for the first time: the unchanged desktop dialog runs EXPORTER_STEP,
whose wasm shadow suspends into globalThis.occService and the export bytes go
straight to a browser download (never entering the editor heap). STEP/IGES 3D
models parse in the worker via the oce shadow (S3D WriteCache/ReadCache wire).
- wasm/occ-service/: service CMake target (hooked from the kicad fork's
top-level CMakeLists, wasm/editor pattern), embind entry
(occExport/occLoadModel), wxConfig pre-js.
- wasm/stubs/{exporter_step,oce_plugin}_stub.cpp: EM_ASYNC_JS worker bridges
(callee-shadowing; no caller #ifdefs).
- web/standalone: provider installed whenever the kicad_editor bundle boots
(cross-face safe); ONE shared worker-boot source occ-worker.js (vite ?raw;
the e2e stub reads the same file) — blob worker with locateFile absolutized
against the glue URL; export download-name guard.
- deps: OCC builds with RapidJSON so its glTF/GLB writer exists — pinned to
the vcpkg master snapshot 2025-02-26 (24b5e7a8b27f), the same code official
KiCad consumes via vcpkg.json's opencascade[rapidjson]; rapidjson's latest
tag (v1.1.0, 2016) is ill-formed under modern clang.
- tests: occ-export dialog e2e (lazy-fetch boundary + STEP download bytes),
occ-probe incl. a 9-format matrix (step/stpz/brep/xao/ply/stl/glb/u3d/pdf),
3d-viewer-models hard-asserts the worker parse; occ provider stub installed
ambiently by the kicad fixtures.
Validated against desktop kicad-cli 10.0.4: geometric exact equality (bbox
delta 0 um, volume delta 0.0000%) for STEP/GLB/STL/BREP/STPZ across three
boards and option sweeps — with desktop OCC 7.9 vs wasm OCC 7.8; PLY/XAO/PDF
structurally equal; U3D same-size (quantizer float LSBs differ). Full kicad
e2e green on Firefox and Chromium; standalone verified end to end (lazy fetch
only on the Export click; export.step 60,628 B ISO-10303-21; loadModel 700 KB
STEP -> 569 KB scenegraph cache).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:39:58 +02:00
|
|
|
// occ_service is the lazy OCC worker module (STEP export + STEP/IGES model
|
|
|
|
|
// parsing for the PCB frames, docs/features/occ-split/) — served, but headless:
|
|
|
|
|
// no wx glue, no image archive.
|
2026-06-18 17:09:40 +02:00
|
|
|
const TOOLS = [
|
2026-07-02 14:48:11 +02:00
|
|
|
"kicad_editor",
|
2026-06-18 17:09:40 +02:00
|
|
|
"pl_editor",
|
|
|
|
|
"gerbview",
|
|
|
|
|
"calculator",
|
feat(wasm): occ-split — lazy occ_service worker; kicad_editor drops OCC (−31%)
Move OpenCASCADE out of the merged editor image into occ_service: a separate
emscripten module (-sASYNCIFY=0, MODULARIZE, in-container -Oz finalize, 2N+8
pre-warmed pthread pool) booted lazily in a dedicated Web Worker on the first
STEP export or STEP/IGES model parse. kicad_editor.wasm ~190 MB -> 130 MB;
sessions that never touch OCC never fetch its 57 MB. STEP export works in the
browser for the first time: the unchanged desktop dialog runs EXPORTER_STEP,
whose wasm shadow suspends into globalThis.occService and the export bytes go
straight to a browser download (never entering the editor heap). STEP/IGES 3D
models parse in the worker via the oce shadow (S3D WriteCache/ReadCache wire).
- wasm/occ-service/: service CMake target (hooked from the kicad fork's
top-level CMakeLists, wasm/editor pattern), embind entry
(occExport/occLoadModel), wxConfig pre-js.
- wasm/stubs/{exporter_step,oce_plugin}_stub.cpp: EM_ASYNC_JS worker bridges
(callee-shadowing; no caller #ifdefs).
- web/standalone: provider installed whenever the kicad_editor bundle boots
(cross-face safe); ONE shared worker-boot source occ-worker.js (vite ?raw;
the e2e stub reads the same file) — blob worker with locateFile absolutized
against the glue URL; export download-name guard.
- deps: OCC builds with RapidJSON so its glTF/GLB writer exists — pinned to
the vcpkg master snapshot 2025-02-26 (24b5e7a8b27f), the same code official
KiCad consumes via vcpkg.json's opencascade[rapidjson]; rapidjson's latest
tag (v1.1.0, 2016) is ill-formed under modern clang.
- tests: occ-export dialog e2e (lazy-fetch boundary + STEP download bytes),
occ-probe incl. a 9-format matrix (step/stpz/brep/xao/ply/stl/glb/u3d/pdf),
3d-viewer-models hard-asserts the worker parse; occ provider stub installed
ambiently by the kicad fixtures.
Validated against desktop kicad-cli 10.0.4: geometric exact equality (bbox
delta 0 um, volume delta 0.0000%) for STEP/GLB/STL/BREP/STPZ across three
boards and option sweeps — with desktop OCC 7.9 vs wasm OCC 7.8; PLY/XAO/PDF
structurally equal; U3D same-size (quantizer float LSBs differ). Full kicad
e2e green on Firefox and Chromium; standalone verified end to end (lazy fetch
only on the Export click; export.step 60,628 B ISO-10303-21; loadModel 700 KB
STEP -> 569 KB scenegraph cache).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:39:58 +02:00
|
|
|
"occ_service",
|
2026-06-18 17:09:40 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Files that make up a self-contained tool bundle. `<tool>` is substituted.
|
|
|
|
|
const SHARED_FILES = ["wx.js", "wx-dom.js", "images.tar.gz"];
|
feat(wasm): occ-split — lazy occ_service worker; kicad_editor drops OCC (−31%)
Move OpenCASCADE out of the merged editor image into occ_service: a separate
emscripten module (-sASYNCIFY=0, MODULARIZE, in-container -Oz finalize, 2N+8
pre-warmed pthread pool) booted lazily in a dedicated Web Worker on the first
STEP export or STEP/IGES model parse. kicad_editor.wasm ~190 MB -> 130 MB;
sessions that never touch OCC never fetch its 57 MB. STEP export works in the
browser for the first time: the unchanged desktop dialog runs EXPORTER_STEP,
whose wasm shadow suspends into globalThis.occService and the export bytes go
straight to a browser download (never entering the editor heap). STEP/IGES 3D
models parse in the worker via the oce shadow (S3D WriteCache/ReadCache wire).
- wasm/occ-service/: service CMake target (hooked from the kicad fork's
top-level CMakeLists, wasm/editor pattern), embind entry
(occExport/occLoadModel), wxConfig pre-js.
- wasm/stubs/{exporter_step,oce_plugin}_stub.cpp: EM_ASYNC_JS worker bridges
(callee-shadowing; no caller #ifdefs).
- web/standalone: provider installed whenever the kicad_editor bundle boots
(cross-face safe); ONE shared worker-boot source occ-worker.js (vite ?raw;
the e2e stub reads the same file) — blob worker with locateFile absolutized
against the glue URL; export download-name guard.
- deps: OCC builds with RapidJSON so its glTF/GLB writer exists — pinned to
the vcpkg master snapshot 2025-02-26 (24b5e7a8b27f), the same code official
KiCad consumes via vcpkg.json's opencascade[rapidjson]; rapidjson's latest
tag (v1.1.0, 2016) is ill-formed under modern clang.
- tests: occ-export dialog e2e (lazy-fetch boundary + STEP download bytes),
occ-probe incl. a 9-format matrix (step/stpz/brep/xao/ply/stl/glb/u3d/pdf),
3d-viewer-models hard-asserts the worker parse; occ provider stub installed
ambiently by the kicad fixtures.
Validated against desktop kicad-cli 10.0.4: geometric exact equality (bbox
delta 0 um, volume delta 0.0000%) for STEP/GLB/STL/BREP/STPZ across three
boards and option sweeps — with desktop OCC 7.9 vs wasm OCC 7.8; PLY/XAO/PDF
structurally equal; U3D same-size (quantizer float LSBs differ). Full kicad
e2e green on Firefox and Chromium; standalone verified end to end (lazy fetch
only on the Export click; export.step 60,628 B ISO-10303-21; loadModel 700 KB
STEP -> 569 KB scenegraph cache).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:39:58 +02:00
|
|
|
const toolFiles = (tool) =>
|
|
|
|
|
tool === "occ_service"
|
|
|
|
|
? [`${tool}.wasm`, `${tool}.js`]
|
|
|
|
|
: [`${tool}.wasm`, `${tool}.js`, ...SHARED_FILES];
|
2026-06-18 17:09:40 +02:00
|
|
|
|
|
|
|
|
// 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();
|