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:
Gergő Törcsvári 2026-06-18 17:09:40 +02:00
commit 5aae2a0d16
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
23 changed files with 1489 additions and 112 deletions

88
.github/workflows/deploy-demo.yml vendored Normal file
View file

@ -0,0 +1,88 @@
name: deploy-demo
# Tag a release (vX.Y.Z) → reuse the already-published WASM (snapshot it into a
# per-release manifest), publish the example gallery, build the standalone pinned
# to the CDN, and deploy it to demo.pcbjam.com (Cloudflare Pages).
#
# The heavy WASM build+upload is DECOUPLED (workflow: publish-wasm.yml / a local
# seed) because the WASM changes rarely; this pipeline never rebuilds it. See
# docs/features/demo-deploy/.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Release tag to deploy, e.g. v1.2.3"
required: true
# Serialize demo deploys so two tags don't race the live site (don't cancel a
# half-finished deploy — let it complete).
concurrency:
group: deploy-demo
cancel-in-progress: false
env:
CDN: https://cdn.pcbjam.com
BUCKET: pcbjam-cdn
PAGES_PROJECT: pcbjam-demo
# MUST be the Pages project's PRODUCTION branch — any other value makes
# `wrangler pages deploy` a PREVIEW deploy and demo.pcbjam.com won't update.
# Direct-Upload projects default to "production".
PAGES_PROD_BRANCH: production
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# The publish scripts shell wrangler; no repo dep — fetch it on demand.
WRANGLER_CMD: npx --yes wrangler@4
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# The standalone needs the MIT pcbjam-shared submodule, but NOT the huge
# kicad/wxwidgets ones (WASM is prebuilt on the CDN, not built here).
- uses: actions/checkout@v4
with:
submodules: false
- name: Init pcbjam-shared submodule
run: git submodule update --init --depth 1 web/pcbjam-shared
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install standalone workspace
run: pnpm --dir web install --frozen-lockfile
- name: Resolve tag
id: tag
run: echo "tag=${GITHUB_REF_NAME:-${{ github.event.inputs.tag }}}" >> "$GITHUB_OUTPUT"
# 1) Reuse prebuilt WASM: snapshot the current registry into manifest-<tag>
# (no build, no upload). Fails if the WASM was never published — run
# publish-wasm.yml (or a local seed) first.
- name: Snapshot WASM manifest
run: >
node scripts/deploy/publish-wasm.mjs --tag "${{ steps.tag.outputs.tag }}"
--driver r2 --bucket "$BUCKET" --remote --from-registry
# 2) Publish the read-only example gallery (tiny; content/<tag>/).
- name: Publish content gallery
run: >
node scripts/deploy/publish-content.mjs --tag "${{ steps.tag.outputs.tag }}"
--gallery deploy/demo/gallery.json --driver r2 --bucket "$BUCKET" --remote
# 3) Build the standalone pinned to the CDN + this tag's manifests.
- name: Build demo
run: node scripts/deploy/build-demo.mjs --tag "${{ steps.tag.outputs.tag }}" --cdn "$CDN"
# 4) Deploy to Cloudflare Pages (demo.pcbjam.com is the project's custom domain).
- name: Deploy to Cloudflare Pages
run: >
npx --yes wrangler@4 pages deploy web/standalone/dist
--project-name "$PAGES_PROJECT"
--branch "$PAGES_PROD_BRANCH"
--commit-dirty=true

89
.github/workflows/publish-wasm.yml vendored Normal file
View file

@ -0,0 +1,89 @@
name: publish-wasm
# Heavy, on-demand: build the KiCad WASM on the Ubicloud runner (same recipe as
# ci-ubicloud.yml, sharing its deps/wx caches) and upload it to the CDN
# (cdn.pcbjam.com, R2 pcbjam-cdn) as per-tool content-addressed folders +
# registry.json. Run this ONLY when the WASM actually changes (KiCad/wxwidgets/
# build flags); the per-release deploy (deploy-demo.yml) reuses whatever this
# publishes. See docs/features/demo-deploy/0001-wasm-cdn-versioning.md.
#
# `wasm_tag` names the folder for any tool whose bytes changed (unchanged tools
# are skipped via the content-hash registry).
on:
workflow_dispatch:
inputs:
wasm_tag:
description: "Version label for changed tools (e.g. kicad-9.0.1)"
required: true
compress:
description: "Compression for .wasm/.js (gzip | br | none)"
default: gzip
concurrency:
group: publish-wasm
cancel-in-progress: false
env:
BUCKET: pcbjam-cdn
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
WRANGLER_CMD: npx --yes wrangler@4
# Match ci-ubicloud.yml so the deps cache volume name + key line up (warm cache).
COMPOSE_PROJECT_NAME: kicad-wasm-ci
BINARYEN_VERSION: "130"
BINARYEN_OPT_LEVEL: "-O1"
jobs:
publish:
runs-on: ubicloud-standard-30
timeout-minutes: 300
steps:
- name: Install build toolchain (Binaryen from-source)
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update
sudo apt-get install -y cmake ninja-build g++ libjemalloc2 autoconf automake make
- uses: actions/checkout@v4
with: { submodules: recursive }
- uses: actions/setup-node@v4
with: { node-version: 20 }
# Reuse ci-ubicloud's deps cache (same key + volume) so --build-deps
# short-circuits when warm.
- name: Restore deps cache
id: deps-cache
uses: actions/cache@v4
with:
path: deps-cache
key: deps-${{ runner.os }}-${{ hashFiles('scripts/deps/**','scripts/common/versions.sh','scripts/common/functions.sh','scripts/common/env.sh','docker/Dockerfile','docker/docker-compose.yml') }}
- name: Seed deps volume from cache
if: steps.deps-cache.outputs.cache-hit == 'true'
run: |
docker volume create kicad-wasm-ci_kicad-build-cache
docker run --rm -v kicad-wasm-ci_kicad-build-cache:/bw -v "$PWD/deps-cache":/cache \
alpine sh -c 'tar xzf /cache/deps.tar.gz -C /bw'
# Full WASM build of every tool the standalone serves → output/*.{wasm,js}
# + wx.js, wx-dom.js, images.tar.gz. NOTE: the published tool set
# (publish-wasm.mjs default) must match what `build.sh all` produces; pass
# `--tools …` if they differ.
- name: Build all KiCad tools WASM
run: |
export KICAD_DOCKER_CPUS="$(( $(nproc) - 1 ))" KICAD_DOCKER_MEM=110G
export KICAD_PIPELINE=1 BINARYEN_CORES=16 BINARYEN_BUILD_FROM_SOURCE=1
./docker/build.sh all --build-deps -j "$(nproc)"
ls -lh output/*.wasm
- name: Package deps for cache
if: steps.deps-cache.outputs.cache-hit != 'true'
run: |
mkdir -p deps-cache
docker run --rm -v kicad-wasm-ci_kicad-build-cache:/bw -v "$PWD/deps-cache":/cache \
alpine sh -c 'cd /bw && tar czf /cache/deps.tar.gz sysroot stamps'
# Content-addressed upload + registry update (idempotent: unchanged tools skip).
- name: Publish WASM to CDN
run: >
node scripts/deploy/publish-wasm.mjs --tag "${{ github.event.inputs.wasm_tag }}"
--src output --driver r2 --bucket "$BUCKET" --remote
--compress "${{ github.event.inputs.compress }}"

73
deploy/demo/README.md Normal file
View file

@ -0,0 +1,73 @@
# demo.pcbjam.com deploy runbook
The no-backend demo: the GPL standalone on **Cloudflare Pages** (`demo.pcbjam.com`),
its WASM + example projects on a versioned **R2 CDN** (`cdn.pcbjam.com`). The
design/spec docs live in the private `pcbjam-private` repo
(`docs/features/demo-deploy/`).
```
git tag vX.Y.Z ──▶ .github/workflows/deploy-demo.yml
1. snapshot current WASM → cdn/wasm/manifest-<tag>.json (reuse prebuilt)
2. publish example gallery → cdn/content/<tag>/
3. build standalone (pinned to CDN + tag) → dist/
4. wrangler pages deploy → demo.pcbjam.com
(separately, only when the WASM changes) publish-wasm.yml (Ubicloud) / local seed
build WASM → cdn/wasm/<tool>/<ver>/ + registry.json
```
Everything here lives in the GPL `pcbjam` repo (the demo *is* the GPL standalone;
no closed code), so `publish-wasm.yml` reuses the existing Ubicloud build infra.
## One-time setup (Cloudflare — needs your account)
1. `pcbjam.com` zone on Cloudflare; note the **account id**.
2. Public bucket: `wrangler r2 bucket create pcbjam-cdn`.
3. Custom domain `cdn.pcbjam.com` → the `pcbjam-cdn` bucket (R2 → Settings → Custom Domains).
4. **Transform Rule** on `cdn.pcbjam.com` (Rules → Transform Rules → Modify Response Header),
"set static" on all requests:
- `Cross-Origin-Resource-Policy: cross-origin`
- `Access-Control-Allow-Origin: *`
(R2 can't set CORP as object metadata; the demo page is COEP `require-corp`.)
5. Cloudflare Pages project `pcbjam-demo`; custom domain `demo.pcbjam.com`.
6. API token (Workers/Pages: Edit, R2: Edit) → this repo's GitHub secrets
`CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
## First-time WASM seed (local — run from the pcbjam repo root; `output/` is already built)
```sh
wrangler login # or export CLOUDFLARE_API_TOKEN + _ACCOUNT_ID
node scripts/deploy/publish-wasm.mjs --tag kicad-9.0.1 \
--src output --driver r2 --bucket pcbjam-cdn --remote
```
This uploads per-tool content-addressed folders + `registry.json`. Re-runs are
idempotent (unchanged tools are skipped). `--tag` names the folder of any tool
whose bytes changed; pick something readable (the KiCad version works well).
> Preview the exact upload offline first with `--driver local --out /tmp/cdn`
> (writes the bucket layout + a `_uploads.json` of every object's HTTP metadata).
## Deploying the demo
Tag a release — `git tag v1.2.3 && git push --tags` — and `deploy-demo.yml` runs.
Or trigger it manually (Actions → deploy-demo → Run, with a tag). It does **not**
rebuild WASM; it snapshots whatever `publish-wasm` last published, so returning
users don't re-download the (large) WASM on releases that didn't change it.
Roll a bad tool back without redeploying the app: edit
`cdn/wasm/manifest-<tag>.json` to point the tool at an older `<ver>` (still in the
bucket); it's served uncached, so the next load picks it up.
## When the WASM changes
Run `publish-wasm.yml` (Actions → publish-wasm → Run, with a `wasm_tag`) — it
builds on Ubicloud (sharing ci-ubicloud's deps cache) and publishes — or re-run
the local seed with a new `--tag`. Then deploy a release as usual; the snapshot
picks up the new versions.
## Add example projects to the gallery
Edit [`gallery.json`](gallery.json) (references source files in the repo; no
copies). Preview: `node scripts/deploy/publish-content.mjs --tag t --driver local
--out /tmp/cdn`.

7
deploy/demo/_headers Normal file
View file

@ -0,0 +1,7 @@
# Cloudflare Pages headers for demo.pcbjam.com.
# Cross-origin isolation is REQUIRED for KiCad WASM (SharedArrayBuffer + threads).
# The big WASM/content blobs are cross-origin on cdn.pcbjam.com and satisfy COEP
# via their own CORP/ACAO headers (a Cloudflare Transform Rule on that hostname).
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

4
deploy/demo/_redirects Normal file
View file

@ -0,0 +1,4 @@
# SPA fallback: client-side routes (/p/:project/:tool, /l/:lib/:tool, …) have no
# static file, so serve index.html with 200. Cloudflare Pages matches real static
# assets (/, /assets/*, favicon, …) FIRST, so this only catches app routes.
/* /index.html 200

12
deploy/demo/gallery.json Normal file
View file

@ -0,0 +1,12 @@
{
"$comment": "Curates the demo.pcbjam.com static gallery. Each project REFERENCES source files already in the repo (root is repo-relative) so we don't duplicate them. scripts/deploy/publish-content.mjs reads the bytes and publishes them to cdn.pcbjam.com/content/<tag>/. Add projects here to grow the gallery.",
"projects": [
{
"slug": "demo-board",
"name": "Demo Board",
"description": "A small example board. Open the PCB or schematic, edit it, and Save downloads the result to your machine — no account, no backend.",
"root": "tests/fixtures/demo",
"files": ["demo.kicad_pcb", "demo.kicad_sch", "demo.kicad_wks"]
}
]
}

View 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();

View 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,
});
}

View 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();

View 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();

View file

@ -5,11 +5,27 @@
# local-folder loader on the home page. # local-folder loader on the home page.
VITE_API_BASE_URL=http://localhost:3060 VITE_API_BASE_URL=http://localhost:3060
# Where the WASM glue/artifacts are served from. SAME-ORIGIN "/wasm" is required # Where PROJECTS come from: "remote" (default — the @pcbjam/shared backend above)
# (KiCad WASM pthread workers cannot be created cross-origin). On `dev` the # or "static" (a read-only example gallery served from a CDN, NO backend — the
# artifacts are symlinked into public/wasm and served by Vite at /wasm. For prod, # demo.pcbjam.com mode; editor saves download to local). For "static" you must
# set an absolute URL whose origin also satisfies the COEP/COOP rules. # also set VITE_PROJECT_MANIFEST_URL.
VITE_WASM_ASSET_BASE_URL=/wasm # VITE_PROJECT_SOURCE=static
# VITE_PROJECT_MANIFEST_URL=https://cdn.pcbjam.com/content/2.7.7/manifest.json
# WASM asset root (no trailing slash). On `dev` the artifacts are symlinked into
# public/wasm and served by Vite at /wasm (a FLAT layout — files live directly
# under the root). For a prod CDN set an absolute URL, e.g.:
# VITE_WASM_ROOT=https://cdn.pcbjam.com/wasm
# A cross-origin CDN works: boot.ts loads the pthread worker via a same-origin
# blob shim, and the CDN must send CORP: cross-origin + ACAO (+ COEP/COOP on the
# app). VITE_WASM_ASSET_BASE_URL is the legacy name and is still honored.
VITE_WASM_ROOT=/wasm
# Per-release WASM manifest file under VITE_WASM_ROOT (e.g. "manifest-2.7.7.json").
# When set, the editor resolves each tool's versioned, content-addressed folder
# (VITE_WASM_ROOT/<tool>/<ver>/) from it at runtime (see docs/features/demo-deploy).
# Leave UNSET for the flat dev layout above.
# VITE_WASM_MANIFEST=manifest-2.7.7.json
# Override the artifact source dir the dev symlink points at (default: # Override the artifact source dir the dev symlink points at (default:
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere. # <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.

View file

@ -14,11 +14,11 @@ import {
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"; import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import { import {
libsSourceConfig, libsSourceConfig,
WASM_ASSET_BASE_URL,
yjsProviderConfig, yjsProviderConfig,
type DocSource, type DocSource,
} from "@/lib/config"; } from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot"; import { bootKicadTool } from "@/wasm/boot";
import { resolveWasmBase } from "@/wasm/wasm-assets";
import { import {
LIB_BUSY_EVENT, LIB_BUSY_EVENT,
LIB_ERROR_EVENT, LIB_ERROR_EVENT,
@ -537,7 +537,8 @@ export function WasmTool({
* to "api" (plain fetch + open). Local-folder sessions don't pass this. * to "api" (plain fetch + open). Local-folder sessions don't pass this.
*/ */
docSource?: DocSource; docSource?: DocSource;
/** Where the WASM glue/artifacts are served from; defaults to VITE_WASM_ASSET_BASE_URL. */ /** Override the resolved WASM asset base (used verbatim, e.g. e2e fixtures).
* Default: resolveWasmBase(tool) the CDN manifest folder, or flat /wasm. */
assetBaseUrl?: string; assetBaseUrl?: string;
}) { }) {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
@ -556,7 +557,6 @@ export function WasmTool({
// Last lib error (e.g. a backend 404 on open), shown as a dismissible toast. // Last lib error (e.g. a backend 404 on open), shown as a dismissible toast.
const [libError, setLibError] = React.useState<string | null>(null); const [libError, setLibError] = React.useState<string | null>(null);
const base = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, "");
const append = React.useCallback( const append = React.useCallback(
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]), (msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
[], [],
@ -644,6 +644,9 @@ export function WasmTool({
void (async () => { void (async () => {
try { try {
// Resolve the per-tool asset base at runtime (CDN manifest → versioned
// folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts.
const base = await resolveWasmBase(tool, assetBaseUrl);
await bootKicadTool({ await bootKicadTool({
tool, tool,
base, base,
@ -773,7 +776,7 @@ export function WasmTool({
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so // Boot is one-shot per mount; deps intentionally exclude files/targetPath so
// they don't retrigger a (rejected) second boot. // they don't retrigger a (rejected) second boot.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tool, slug, base, append]); }, [tool, slug, assetBaseUrl, append]);
return ( return (
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]"> <div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">

View file

@ -1,47 +1,34 @@
import { import type { DriftReportBody, Lib } from "@pcbjam/shared";
contract,
type DriftReportBody,
type Lib,
type Project,
type ProjectWithFiles,
} from "@pcbjam/shared";
import { initClient } from "@ts-rest/core";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { API_BASE_URL } from "./config"; import { API_BASE_URL, PROJECT_SOURCE_KIND } from "./config";
import { client } from "./contract-client";
import { downloadBytes } from "./download";
import { projectSource } from "./project-source";
/** /**
* Client over the shared contract. The standalone editor READS projects from a * Project/file reads go through the active PROJECT SOURCE (lib/project-source.ts):
* backend (enumerate, get file tree, stream bytes) and writes back exactly one * the @pcbjam/shared REST backend, or the read-only static gallery (demo mode).
* thing: the bytes of a file the user explicitly saved in the editor (see * Libraries + collab drift reporting are backend-only and stay on the contract
* uploadFileBytes). Project management (create/delete/bulk upload) stays in the * client here.
* closed application that hosts this editor.
*/ */
export const client = initClient(contract, {
baseUrl: API_BASE_URL,
baseHeaders: {},
});
export function useProjects() { export function useProjects() {
return useQuery({ return useQuery({
queryKey: ["projects"], queryKey: ["projects"],
queryFn: async (): Promise<Project[]> => { queryFn: () => projectSource().listProjects(),
const res = await client.listProjects();
if (res.status !== 200) throw new Error("failed to list projects");
return res.body;
},
}); });
} }
/** /**
* Libraries the backend serves, optionally filtered to a kind ("symbol" | * Libraries the backend serves, optionally filtered to a kind. In static (no
* "footprint"). Origins are kind-filtered server-side; user libs are * backend) mode there are none, so we short-circuit to an empty list rather than
* kind-agnostic and always returned. Mirrors `useProjects` read-only listing * fire a doomed request.
* for the home page; the editor consumes libs over its own WASM bridge.
*/ */
export function useLibs(kind?: "symbol" | "footprint") { export function useLibs(kind?: "symbol" | "footprint") {
return useQuery({ return useQuery({
queryKey: ["libs", kind ?? "all"], queryKey: ["libs", kind ?? "all"],
queryFn: async (): Promise<Lib[]> => { queryFn: async (): Promise<Lib[]> => {
if (PROJECT_SOURCE_KIND === "static") return [];
const res = await client.listLibs({ query: { kind } }); const res = await client.listLibs({ query: { kind } });
if (res.status !== 200) throw new Error("failed to list libraries"); if (res.status !== 200) throw new Error("failed to list libraries");
return res.body; return res.body;
@ -52,35 +39,38 @@ export function useLibs(kind?: "symbol" | "footprint") {
export function useProject(slug: string) { export function useProject(slug: string) {
return useQuery({ return useQuery({
queryKey: ["project", slug], queryKey: ["project", slug],
queryFn: async (): Promise<ProjectWithFiles> => { queryFn: () => projectSource().getProject(slug),
const res = await client.getProject({ params: { project: slug } });
if (res.status === 404) throw new Error("project not found");
if (res.status !== 200) throw new Error("failed to load project");
return res.body;
},
}); });
} }
// --- raw file-byte download (streamed binary, not a ts-rest endpoint) --- /** File bytes from the active source (backend stream, or the static CDN gallery). */
export function fetchFileBytes(
export function fileBytesUrl(slug: string, relPath: string): string {
const encoded = relPath
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
return `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encoded}`;
}
export async function fetchFileBytes(
slug: string, slug: string,
relPath: string, relPath: string,
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const res = await fetch(fileBytesUrl(slug, relPath)); return projectSource().fetchFileBytes(slug, relPath);
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer());
} }
// --- collaboration drift reporting (ysync) --- /**
* Persist a saved file. A writable source (the backend) uploads it; a read-only
* source (the static demo gallery) has no upload target, so the save downloads
* to the user's machine instead. The remote-vs-static choice is config-driven
* (the active project source), so callers just call this.
*/
export function uploadFileBytes(
slug: string,
relPath: string,
bytes: Uint8Array,
): Promise<void> {
const source = projectSource();
if (source.uploadFileBytes) {
return source.uploadFileBytes(slug, relPath, bytes);
}
downloadBytes(relPath, bytes);
return Promise.resolve();
}
// --- collaboration drift reporting (ysync; backend-only) ---
/** /**
* Report a detected ydoc/wasm drift (the editor's periodic, every-N-edits check). * Report a detected ydoc/wasm drift (the editor's periodic, every-N-edits check).
@ -109,24 +99,3 @@ export function reportDriftBeacon(slug: string, body: DriftReportBody): void {
} }
void fetch(url, { method: "POST", body: blob, keepalive: true }).catch(() => {}); void fetch(url, { method: "POST", body: blob, keepalive: true }).catch(() => {});
} }
/**
* Persist one saved file back to the backend via the multipart upload route
* (POST /api/projects/:project/files upserts by (project, path); the form
* FIELD NAME carries the project-relative path, same convention as the
* management app's folder upload).
*/
export async function uploadFileBytes(
slug: string,
relPath: string,
bytes: Uint8Array,
): Promise<void> {
const name = relPath.split("/").pop() ?? relPath;
const form = new FormData();
form.append(relPath, new File([bytes as BlobPart], name));
const res = await fetch(
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
{ method: "POST", body: form },
);
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
}

View file

@ -1,12 +1,44 @@
export const API_BASE_URL = export const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3050"; import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3050";
// Default is SAME-ORIGIN ("/wasm", served from public/wasm by Vite). KiCad WASM // Where the KiCad WASM artifacts are served from (no trailing slash).
// pthread workers cannot be created cross-origin, so dev must serve same-origin. // dev / same-origin: "/wasm" (flat layout, served from public/wasm by Vite).
// Override with an absolute URL (e.g. a CDN) only if that origin is configured // prod CDN: VITE_WASM_ROOT, e.g. "https://cdn.pcbjam.com/wasm".
// to also satisfy the worker/COEP constraints. // A cross-origin CDN works because boot.ts loads the pthread worker through a
export const WASM_ASSET_BASE_URL = // same-origin blob shim (see wasm/boot.ts) and the CDN sets CORP/ACAO.
import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm"; // VITE_WASM_ASSET_BASE_URL is the legacy name and is still honored.
export const WASM_ROOT = (
import.meta.env.VITE_WASM_ROOT ??
import.meta.env.VITE_WASM_ASSET_BASE_URL ??
"/wasm"
).replace(/\/+$/, "");
// Per-release WASM manifest file under WASM_ROOT (e.g. "manifest-2.7.7.json").
// When set, the standalone resolves each tool's versioned, content-addressed
// folder (WASM_ROOT/<tool>/<ver>/) from it AT RUNTIME — see wasm/wasm-assets.ts,
// so a tool can be repointed after a bad deploy without rebuilding the app.
// Unset ⇒ flat layout directly under WASM_ROOT (dev / same-origin). The manifest
// is fetched uncached; the tool folders it points at are immutable + long-cached.
export const WASM_MANIFEST_FILE = import.meta.env.VITE_WASM_MANIFEST || null;
/** @deprecated Use WASM_ROOT + resolveWasmBase(). Kept for back-compat. */
export const WASM_ASSET_BASE_URL = WASM_ROOT;
/**
* Where the standalone reads PROJECTS from (env VITE_PROJECT_SOURCE):
* "remote" (default) the @pcbjam/shared REST backend at API_BASE_URL.
* "static" a read-only example gallery published to a CDN as a
* manifest + file bytes (no backend), e.g. the
* demo.pcbjam.com gallery. Editor saves download to local.
* Needs VITE_PROJECT_MANIFEST_URL. See lib/project-source.ts.
*/
export type ProjectSourceKind = "remote" | "static";
export const PROJECT_SOURCE_KIND: ProjectSourceKind =
import.meta.env.VITE_PROJECT_SOURCE === "static" ? "static" : "remote";
/** Full URL of the static gallery manifest, e.g.
* "https://cdn.pcbjam.com/content/2.7.7/manifest.json". Required for "static". */
export const PROJECT_MANIFEST_URL = import.meta.env.VITE_PROJECT_MANIFEST_URL || null;
import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { remoteLibsSource } from "@/wasm/libs/remote-source"; import { remoteLibsSource } from "@/wasm/libs/remote-source";

View file

@ -0,0 +1,14 @@
import { contract } from "@pcbjam/shared";
import { initClient } from "@ts-rest/core";
import { API_BASE_URL } from "./config";
/**
* ts-rest client over the shared contract (the REST backend). Shared by the
* remote project source (lib/project-source.ts) and the lib/drift endpoints
* (lib/api.ts). Kept in its own module so project-source and api don't import
* each other (avoids a cycle).
*/
export const client = initClient(contract, {
baseUrl: API_BASE_URL,
baseHeaders: {},
});

View file

@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const MANIFEST_URL = "https://cdn.pcbjam.com/content/2.7.7/manifest.json";
const MANIFEST = {
schema: 1,
tag: "2.7.7",
builtAt: "2026-06-18T00:00:00.000Z",
projects: [
{
slug: "demo-board",
name: "Demo Board",
description: "d",
files: [
{ path: "demo.kicad_pcb", size: 100 },
{ path: "sub/x.kicad_sch", size: 5 },
],
},
],
};
// project-source reads config at import time; mock it fresh then dynamic-import.
async function loadStatic() {
vi.resetModules();
vi.doMock("@/lib/config", () => ({
API_BASE_URL: "http://localhost:3050",
PROJECT_SOURCE_KIND: "static",
PROJECT_MANIFEST_URL: MANIFEST_URL,
}));
return (await import("./project-source")).projectSource;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe("static project source", () => {
it("is read-only with no upload target (saves download to local)", async () => {
vi.stubGlobal("fetch", vi.fn());
const src = (await loadStatic())();
expect(src.readOnly).toBe(true);
expect(src.uploadFileBytes).toBeUndefined();
});
it("lists projects from the manifest with stable uuid ids", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => MANIFEST })),
);
const src = (await loadStatic())();
const projects = await src.listProjects();
expect(projects).toHaveLength(1);
const p = projects[0]!;
expect(p.slug).toBe("demo-board");
expect(p.name).toBe("Demo Board");
expect(p.id).toMatch(UUID_RE);
// Deterministic: the same slug resolves to the same id across calls.
expect((await src.listProjects())[0]!.id).toBe(p.id);
});
it("returns a project's file tree; throws for an unknown slug", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => MANIFEST })),
);
const src = (await loadStatic())();
const pwf = await src.getProject("demo-board");
expect(pwf.files.map((f) => f.path)).toEqual([
"demo.kicad_pcb",
"sub/x.kicad_sch",
]);
expect(pwf.files[0]!.projectId).toBe(pwf.project.id);
await expect(src.getProject("nope")).rejects.toThrow(/project not found/);
});
it("fetches bytes from <manifestDir>/<slug>/<path> and caches the manifest", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const fetchMock = vi.fn(async (url: string, _opts?: RequestInit) =>
url === MANIFEST_URL
? { ok: true, json: async () => MANIFEST }
: { ok: true, arrayBuffer: async () => bytes.buffer },
);
vi.stubGlobal("fetch", fetchMock);
const src = (await loadStatic())();
await src.listProjects(); // loads the manifest once
const got = await src.fetchFileBytes("demo-board", "sub/x.kicad_sch");
expect(Array.from(got)).toEqual([1, 2, 3]);
expect(fetchMock).toHaveBeenCalledWith(
"https://cdn.pcbjam.com/content/2.7.7/demo-board/sub/x.kicad_sch",
);
// Manifest fetched exactly once (in-memory cached), uncached over the network.
const manifestCalls = fetchMock.mock.calls.filter((c) => c[0] === MANIFEST_URL);
expect(manifestCalls).toHaveLength(1);
expect(manifestCalls[0]![1]).toEqual({ cache: "no-store" });
});
});

View file

@ -0,0 +1,199 @@
import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
import {
API_BASE_URL,
PROJECT_MANIFEST_URL,
PROJECT_SOURCE_KIND,
} from "./config";
import { client } from "./contract-client";
/**
* Where the standalone gets its PROJECTS. The default `remote` source talks the
* @pcbjam/shared REST contract; the `static` source serves a read-only example
* gallery (manifest + file bytes) from a CDN with no backend the
* demo.pcbjam.com mode, where Save downloads to local (`uploadFileBytes` absent
* the caller downloads). One source is active per deployment, selected by
* PROJECT_SOURCE_KIND. See docs/features/demo-deploy/.
*/
export interface ProjectSource {
/** No write-back target — the editor should download saves to local. */
readonly readOnly: boolean;
listProjects(): Promise<Project[]>;
getProject(slug: string): Promise<ProjectWithFiles>;
fetchFileBytes(slug: string, relPath: string): Promise<Uint8Array>;
/** Present only on writable sources; absent ⇒ read-only (download on save). */
uploadFileBytes?(
slug: string,
relPath: string,
bytes: Uint8Array,
): Promise<void>;
}
// --- remote (REST backend over the shared contract) ---------------------------
function encodePath(relPath: string): string {
return relPath
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
}
function remoteProjectSource(): ProjectSource {
const fileUrl = (slug: string, relPath: string) =>
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
return {
readOnly: false,
async listProjects() {
const res = await client.listProjects();
if (res.status !== 200) throw new Error("failed to list projects");
return res.body;
},
async getProject(slug) {
const res = await client.getProject({ params: { project: slug } });
if (res.status === 404) throw new Error("project not found");
if (res.status !== 200) throw new Error("failed to load project");
return res.body;
},
async fetchFileBytes(slug, relPath) {
const res = await fetch(fileUrl(slug, relPath));
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer());
},
async uploadFileBytes(slug, relPath, bytes) {
const name = relPath.split("/").pop() ?? relPath;
const form = new FormData();
// The form FIELD NAME carries the project-relative path (upsert by
// (project, path)) — same convention as the management app's folder upload.
form.append(relPath, new File([bytes as BlobPart], name));
const res = await fetch(
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
{ method: "POST", body: form },
);
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
},
};
}
// --- static (read-only gallery: manifest + file bytes on a CDN) ---------------
interface StaticManifestFile {
path: string;
size?: number;
}
interface StaticManifestProject {
slug: string;
name: string;
description?: string;
files: StaticManifestFile[];
}
interface StaticManifest {
schema: number;
tag: string;
builtAt?: string;
projects: StaticManifestProject[];
}
/** Stable v4-format UUID from a seed (cyrb128) the contract ids are UUIDs and
* the editor uses project.id for the (broadcast-only here) collab room name, so
* a deterministic id keeps that stable across reloads/tabs. */
function deterministicUuid(seed: string): string {
let h1 = 1779033703,
h2 = 3144134277,
h3 = 1013904242,
h4 = 2773480762;
for (let i = 0; i < seed.length; i++) {
const k = seed.charCodeAt(i);
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
}
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
const hex = (n: number) => (n >>> 0).toString(16).padStart(8, "0");
const h = hex(h1) + hex(h2) + hex(h3) + hex(h4);
const variant = ((parseInt(h.charAt(16), 16) & 0x3) | 0x8).toString(16);
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
}
function contentTypeFor(path: string): string {
if (/\.(kicad_\w+|net|csv|pos|drl|gbr)$/i.test(path))
return "text/plain; charset=utf-8";
return "application/octet-stream";
}
function staticProjectSource(manifestUrl: string): ProjectSource {
// Directory that holds the manifest — file bytes live at <dir>/<slug>/<path>.
const baseDir = manifestUrl.replace(/\/[^/]*$/, "");
let manifestP: Promise<StaticManifest> | null = null;
const load = () =>
(manifestP ??= (async () => {
const res = await fetch(manifestUrl, { cache: "no-store" });
if (!res.ok) throw new Error(`gallery manifest ${res.status}: ${manifestUrl}`);
return (await res.json()) as StaticManifest;
})());
const toProject = (p: StaticManifestProject, ts: string): Project => ({
id: deterministicUuid(`project:${p.slug}`),
slug: p.slug,
name: p.name,
createdAt: ts,
updatedAt: ts,
});
const toFile = (
p: StaticManifestProject,
f: StaticManifestFile,
ts: string,
): ProjectFile => ({
id: deterministicUuid(`file:${p.slug}/${f.path}`),
projectId: deterministicUuid(`project:${p.slug}`),
path: f.path,
size: f.size ?? 0,
contentType: contentTypeFor(f.path),
createdAt: ts,
updatedAt: ts,
});
const find = async (slug: string) => {
const m = await load();
const p = m.projects.find((x) => x.slug === slug);
if (!p) throw new Error(`project not found: ${slug}`);
return { m, p };
};
return {
readOnly: true,
async listProjects() {
const m = await load();
const ts = m.builtAt ?? new Date(0).toISOString();
return m.projects.map((p) => toProject(p, ts));
},
async getProject(slug) {
const { m, p } = await find(slug);
const ts = m.builtAt ?? new Date(0).toISOString();
return { project: toProject(p, ts), files: p.files.map((f) => toFile(p, f, ts)) };
},
async fetchFileBytes(slug, relPath) {
const url = `${baseDir}/${encodeURIComponent(slug)}/${encodePath(relPath)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer());
},
// No uploadFileBytes ⇒ read-only; the editor downloads saves to local.
};
}
// --- selection ----------------------------------------------------------------
let cached: ProjectSource | null = null;
/** The active project source for this deployment (memoized). */
export function projectSource(): ProjectSource {
if (cached) return cached;
cached =
PROJECT_SOURCE_KIND === "static" && PROJECT_MANIFEST_URL
? staticProjectSource(PROJECT_MANIFEST_URL)
: remoteProjectSource();
return cached;
}

View file

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import type { Lib, Tool } from "@pcbjam/shared"; import type { Lib, Tool } from "@pcbjam/shared";
import { FolderOpen, Library, Loader2, Package } from "lucide-react"; import { FolderOpen, Library, Loader2, Package } from "lucide-react";
import { useLibs, useProjects } from "@/lib/api"; import { useLibs, useProjects } from "@/lib/api";
import { PROJECT_SOURCE_KIND } from "@/lib/config";
import { localFileLibsSource } from "@/wasm/libs/local-file-source"; import { localFileLibsSource } from "@/wasm/libs/local-file-source";
import type { LibsSource } from "@/wasm/libs/source"; import type { LibsSource } from "@/wasm/libs/source";
import { downloadBytes } from "@/lib/download"; import { downloadBytes } from "@/lib/download";
@ -101,6 +102,9 @@ function buildLocalProject(fileList: FileList): LocalProject {
} }
export function HomePage() { export function HomePage() {
// Static (no-backend) demo mode: projects come from a read-only CDN gallery,
// there are no backend libraries, and editor saves download to local.
const staticMode = PROJECT_SOURCE_KIND === "static";
const { data: projects, isLoading, error } = useProjects(); const { data: projects, isLoading, error } = useProjects();
const symbolLibs = useLibs("symbol"); const symbolLibs = useLibs("symbol");
const footprintLibs = useLibs("footprint"); const footprintLibs = useLibs("footprint");
@ -190,8 +194,9 @@ export function HomePage() {
<div className="container max-w-3xl py-10"> <div className="container max-w-3xl py-10">
<h1 className="text-2xl font-semibold tracking-tight">PCBJam</h1> <h1 className="text-2xl font-semibold tracking-tight">PCBJam</h1>
<p className="mb-8 text-sm text-muted-foreground"> <p className="mb-8 text-sm text-muted-foreground">
Open KiCad files in the browser from a backend, or straight from a {staticMode
local folder. ? "Edit KiCad files in your browser — open an example below or your own local folder. Nothing is uploaded; Save downloads to your machine."
: "Open KiCad files in the browser — from a backend, or straight from a local folder."}
</p> </p>
{/* --- Local folder --- */} {/* --- Local folder --- */}
@ -243,9 +248,11 @@ export function HomePage() {
<ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} /> <ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} />
</section> </section>
{/* --- Backend projects --- */} {/* --- Projects (backend, or the static example gallery) --- */}
<section className="mb-10"> <section className="mb-10">
<h2 className="mb-3 text-lg font-medium">Projects from the backend</h2> <h2 className="mb-3 text-lg font-medium">
{staticMode ? "Example projects" : "Projects from the backend"}
</h2>
{isLoading && ( {isLoading && (
<p className="flex items-center gap-2 text-muted-foreground"> <p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading <Loader2 className="animate-spin" /> loading
@ -253,8 +260,9 @@ export function HomePage() {
)} )}
{error && ( {error && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No backend reachable ({(error as Error).message}). Use a local folder {staticMode
above, or configure VITE_API_BASE_URL. ? `Couldn't load the example gallery (${(error as Error).message}). Use a local folder above.`
: `No backend reachable (${(error as Error).message}). Use a local folder above, or configure VITE_API_BASE_URL.`}
</p> </p>
)} )}
<div className="divide-y rounded-lg border"> <div className="divide-y rounded-lg border">
@ -274,30 +282,32 @@ export function HomePage() {
))} ))}
{projects && projects.length === 0 && ( {projects && projects.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground"> <div className="px-4 py-6 text-sm text-muted-foreground">
The backend has no projects. {staticMode ? "No example projects." : "The backend has no projects."}
</div> </div>
)} )}
</div> </div>
</section> </section>
{/* --- Backend libraries --- */} {/* --- Backend libraries (hidden in the no-backend static demo) --- */}
<section> {!staticMode && (
<h2 className="mb-3 text-lg font-medium">Libraries from the backend</h2> <section>
<div className="space-y-3"> <h2 className="mb-3 text-lg font-medium">Libraries from the backend</h2>
<LibGroup <div className="space-y-3">
icon={<Library size={16} />} <LibGroup
label="Symbols" icon={<Library size={16} />}
query={symbolLibs} label="Symbols"
tool="symbol_editor" query={symbolLibs}
/> tool="symbol_editor"
<LibGroup />
icon={<Package size={16} />} <LibGroup
label="Footprints" icon={<Package size={16} />}
query={footprintLibs} label="Footprints"
tool="footprint_editor" query={footprintLibs}
/> tool="footprint_editor"
</div> />
</section> </div>
</section>
)}
</div> </div>
); );
} }

View file

@ -43,6 +43,8 @@ export function ToolPage() {
// PreflightGate runs the device-capability check; on a fatal mismatch it blocks // PreflightGate runs the device-capability check; on a fatal mismatch it blocks
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped. // here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
// fetch/upload go through the active project source (api.ts): a backend
// project uploads saves; the static gallery downloads them to local.
return ( return (
<PreflightGate> <PreflightGate>
<WasmTool <WasmTool

View file

@ -2,7 +2,16 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string; readonly VITE_API_BASE_URL?: string;
/** WASM asset root, no trailing slash. Dev: "/wasm". Prod CDN: e.g. "https://cdn.pcbjam.com/wasm". */
readonly VITE_WASM_ROOT?: string;
/** Per-release WASM manifest file under VITE_WASM_ROOT (e.g. "manifest-2.7.7.json"); enables versioned per-tool folders. */
readonly VITE_WASM_MANIFEST?: string;
/** @deprecated legacy alias for VITE_WASM_ROOT. */
readonly VITE_WASM_ASSET_BASE_URL?: string; readonly VITE_WASM_ASSET_BASE_URL?: string;
/** Project source: "remote" (default, REST backend) | "static" (read-only CDN gallery, no backend). */
readonly VITE_PROJECT_SOURCE?: string;
/** Static gallery manifest URL (required when VITE_PROJECT_SOURCE=static), e.g. https://cdn.pcbjam.com/content/2.7.7/manifest.json. */
readonly VITE_PROJECT_MANIFEST_URL?: string;
/** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */ /** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */
readonly VITE_YJS_PROVIDER?: string; readonly VITE_YJS_PROVIDER?: string;
/** Host/URL for network collab providers (partykit, hocuspocus). */ /** Host/URL for network collab providers (partykit, hocuspocus). */

View file

@ -32,8 +32,10 @@ const DEFAULT_USER_LIB_NAME = "My Symbols";
* - `locateFile` is overridden to resolve `<base>/<file>`, so the .wasm and the * - `locateFile` is overridden to resolve `<base>/<file>`, so the .wasm and the
* pthread worker script are fetched from the asset dir regardless of the * pthread worker script are fetched from the asset dir regardless of the
* SPA route the user is on. * SPA route the user is on.
* - `mainScriptUrlOrBlob` pins the pthread worker to `<base>/<tool>.js` * - `mainScriptUrlOrBlob` pins the pthread worker to `<base>/<tool>.js`. For a
* (same-origin required: KiCad's pthreads cannot spawn cross-origin). * same-origin base that's the URL directly; for a cross-origin CDN base it's
* a same-origin blob shim that importScripts the glue (see
* `pthreadWorkerScript` `new Worker(<cross-origin URL>)` is illegal).
* *
* Single-instance: the build owns process-global state (one `Module`, one wasm * Single-instance: the build owns process-global state (one `Module`, one wasm
* memory) so only ONE tool can run per page load. A second boot switching * memory) so only ONE tool can run per page load. A second boot switching
@ -91,6 +93,27 @@ function loadScript(src: string): Promise<void> {
}); });
} }
/**
* The pthread worker "script" passed as `Module.mainScriptUrlOrBlob`. KiCad's
* pthreads spawn CLASSIC workers via `new Worker(...)` (see `<tool>.js`
* `allocateUnusedWorker`):
* - SAME-ORIGIN base the plain URL string (the proven local/dev path).
* - CROSS-ORIGIN base (the CDN) a SAME-ORIGIN `blob:` worker that
* `importScripts()` the cross-origin glue. `new Worker(<cross-origin URL>)`
* is a SecurityError, but a `blob:` URL inherits the page origin (legal),
* and a classic worker's `importScripts` MAY load a cross-origin script when
* the CDN sends `Cross-Origin-Resource-Policy: cross-origin` (needed because
* the page is COEP `require-corp`). The `.wasm`/`images.tar.gz` fetches just
* need `ACAO` + `CORP` on the CDN. See docs/features/demo-deploy/0001-*.
*/
function pthreadWorkerScript(base: string, tool: Tool): string | Blob {
const abs = new URL(`${base}/${tool}.js`, window.location.href);
if (abs.origin === window.location.origin) return `${base}/${tool}.js`;
return new Blob([`importScripts(${JSON.stringify(abs.href)});`], {
type: "text/javascript",
});
}
async function doBoot(opts: BootOptions): Promise<void> { async function doBoot(opts: BootOptions): Promise<void> {
const { tool, base, container, log, onStatus, onAbort, libsSource } = opts; const { tool, base, container, log, onStatus, onAbort, libsSource } = opts;
const w = window as ToolWindow; const w = window as ToolWindow;
@ -276,8 +299,9 @@ async function doBoot(opts: BootOptions): Promise<void> {
}, },
// Resolve wasm + pthread worker against the asset base, not the SPA route. // Resolve wasm + pthread worker against the asset base, not the SPA route.
locateFile: (path: string) => `${base}/${path}`, locateFile: (path: string) => `${base}/${path}`,
// Pin the pthread worker script (must be same-origin). // Pin the pthread worker script. Same-origin → direct URL; cross-origin CDN
mainScriptUrlOrBlob: `${base}/${tool}.js`, // → a same-origin blob shim that importScripts the glue (see helper above).
mainScriptUrlOrBlob: pthreadWorkerScript(base, tool),
}; };
// Load order mirrors the harness HTML (tests/apps/kicad/<tool>.html): // Load order mirrors the harness HTML (tests/apps/kicad/<tool>.html):

View file

@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Tool } from "@pcbjam/shared";
// resolveWasmBase reads WASM_ROOT / WASM_MANIFEST_FILE from config at import
// time, so each case mocks config fresh then dynamically imports the module.
async function loadResolver(cfg: {
WASM_ROOT: string;
WASM_MANIFEST_FILE: string | null;
}) {
vi.resetModules();
vi.doMock("@/lib/config", () => cfg);
return (await import("./wasm-assets")).resolveWasmBase;
}
const PCBNEW = "pcbnew" as Tool;
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("resolveWasmBase", () => {
it("uses an explicit override verbatim (trailing slash stripped), no fetch", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const resolve = await loadResolver({
WASM_ROOT: "/wasm",
WASM_MANIFEST_FILE: "manifest-1.json",
});
expect(await resolve(PCBNEW, "https://cdn.example/wasm/pcbnew/9/")).toBe(
"https://cdn.example/wasm/pcbnew/9",
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns the flat root when no manifest is configured (dev), no fetch", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const resolve = await loadResolver({
WASM_ROOT: "/wasm",
WASM_MANIFEST_FILE: null,
});
expect(await resolve(PCBNEW)).toBe("/wasm");
expect(fetchMock).not.toHaveBeenCalled();
});
it("resolves the per-tool versioned folder from the manifest", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
schema: 1,
tag: "2.7.7",
tools: { pcbnew: "2.7.5", eeschema: "2.7.1" },
}),
}));
vi.stubGlobal("fetch", fetchMock);
const resolve = await loadResolver({
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
});
expect(await resolve(PCBNEW)).toBe("https://cdn.pcbjam.com/wasm/pcbnew/2.7.5");
// Manifest is fetched uncached, and only ONCE across calls (in-memory cached).
expect(await resolve("eeschema" as Tool)).toBe(
"https://cdn.pcbjam.com/wasm/eeschema/2.7.1",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://cdn.pcbjam.com/wasm/manifest-2.7.7.json",
{ cache: "no-store" },
);
});
it("throws when the manifest omits the tool", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ tools: {} }) })),
);
const resolve = await loadResolver({
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
});
await expect(resolve(PCBNEW)).rejects.toThrow(/no WASM version for "pcbnew"/);
});
it("throws when the manifest fetch fails", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404 })));
const resolve = await loadResolver({
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
});
await expect(resolve(PCBNEW)).rejects.toThrow(/WASM manifest 404/);
});
});

View file

@ -0,0 +1,51 @@
import type { Tool } from "@pcbjam/shared";
import { WASM_MANIFEST_FILE, WASM_ROOT } from "@/lib/config";
/**
* Resolve the per-tool WASM asset base at runtime from the CDN release manifest.
*
* The CDN stores each tool in a content-addressed, immutable, self-contained
* folder `WASM_ROOT/<tool>/<ver>/` (`<tool>.wasm`, `<tool>.js`, `wx.js`,
* `wx-dom.js`, `images.tar.gz`). A per-release `manifest-<appTag>.json` maps
* `tool -> ver`; we fetch it ONCE per page load (uncached, so a manifest edit
* e.g. rolling a bad tool back to an older folder takes effect on the next
* load with no app rebuild). See docs/features/demo-deploy/0001-*.
*
* No manifest configured (dev / same-origin) the flat `WASM_ROOT` layout.
*/
export interface WasmManifest {
schema: number;
tag: string;
tools: Record<string, string>;
}
let manifestPromise: Promise<WasmManifest> | null = null;
function loadManifest(): Promise<WasmManifest> {
return (manifestPromise ??= (async () => {
const url = `${WASM_ROOT}/${WASM_MANIFEST_FILE}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`WASM manifest ${res.status}: ${url}`);
return (await res.json()) as WasmManifest;
})());
}
/**
* Asset base (no trailing slash) for `bootKicadTool({ base })`.
* - `override` (e.g. an e2e fixture) wins, used verbatim.
* - no manifest flat `WASM_ROOT`.
* - manifest `WASM_ROOT/<tool>/<ver>` from `manifest-<appTag>.json`.
*/
export async function resolveWasmBase(
tool: Tool,
override?: string,
): Promise<string> {
if (override) return override.replace(/\/+$/, "");
if (!WASM_MANIFEST_FILE) return WASM_ROOT; // flat (dev / same-origin)
const manifest = await loadManifest();
const ver = manifest.tools?.[tool];
if (!ver) {
throw new Error(`no WASM version for "${tool}" in ${WASM_MANIFEST_FILE}`);
}
return `${WASM_ROOT}/${tool}/${ver}`;
}