From 5aae2a0d1664bb593f308033aa40a43cef48d330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Thu, 18 Jun 2026 17:09:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(demo):=20demo.pcbjam.com=20deploy=20?= =?UTF-8?q?=E2=80=94=20versioned=20WASM=20CDN=20+=20static=20gallery=20+?= =?UTF-8?q?=20tag=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/deploy-demo.yml | 88 ++++++ .github/workflows/publish-wasm.yml | 89 +++++++ deploy/demo/README.md | 73 +++++ deploy/demo/_headers | 7 + deploy/demo/_redirects | 4 + deploy/demo/gallery.json | 12 + scripts/deploy/build-demo.mjs | 97 +++++++ scripts/deploy/lib/cdn-store.mjs | 136 ++++++++++ scripts/deploy/publish-content.mjs | 100 +++++++ scripts/deploy/publish-wasm.mjs | 250 ++++++++++++++++++ web/standalone/.env.example | 26 +- web/standalone/src/components/WasmTool.tsx | 11 +- web/standalone/src/lib/api.ts | 107 +++----- web/standalone/src/lib/config.ts | 44 ++- web/standalone/src/lib/contract-client.ts | 14 + web/standalone/src/lib/project-source.test.ts | 99 +++++++ web/standalone/src/lib/project-source.ts | 199 ++++++++++++++ web/standalone/src/pages/HomePage.tsx | 60 +++-- web/standalone/src/pages/ToolPage.tsx | 2 + web/standalone/src/vite-env.d.ts | 9 + web/standalone/src/wasm/boot.ts | 32 ++- web/standalone/src/wasm/wasm-assets.test.ts | 93 +++++++ web/standalone/src/wasm/wasm-assets.ts | 51 ++++ 23 files changed, 1490 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/deploy-demo.yml create mode 100644 .github/workflows/publish-wasm.yml create mode 100644 deploy/demo/README.md create mode 100644 deploy/demo/_headers create mode 100644 deploy/demo/_redirects create mode 100644 deploy/demo/gallery.json create mode 100644 scripts/deploy/build-demo.mjs create mode 100644 scripts/deploy/lib/cdn-store.mjs create mode 100644 scripts/deploy/publish-content.mjs create mode 100644 scripts/deploy/publish-wasm.mjs create mode 100644 web/standalone/src/lib/contract-client.ts create mode 100644 web/standalone/src/lib/project-source.test.ts create mode 100644 web/standalone/src/lib/project-source.ts create mode 100644 web/standalone/src/wasm/wasm-assets.test.ts create mode 100644 web/standalone/src/wasm/wasm-assets.ts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 0000000..acc02ef --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -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- + # (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//). + - 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 diff --git a/.github/workflows/publish-wasm.yml b/.github/workflows/publish-wasm.yml new file mode 100644 index 0000000..d468b05 --- /dev/null +++ b/.github/workflows/publish-wasm.yml @@ -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 }}" diff --git a/deploy/demo/README.md b/deploy/demo/README.md new file mode 100644 index 0000000..598eb96 --- /dev/null +++ b/deploy/demo/README.md @@ -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-.json (reuse prebuilt) + 2. publish example gallery → cdn/content// + 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/// + 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-.json` to point the tool at an older `` (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`. diff --git a/deploy/demo/_headers b/deploy/demo/_headers new file mode 100644 index 0000000..cd16a51 --- /dev/null +++ b/deploy/demo/_headers @@ -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 diff --git a/deploy/demo/_redirects b/deploy/demo/_redirects new file mode 100644 index 0000000..0e360ba --- /dev/null +++ b/deploy/demo/_redirects @@ -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 diff --git a/deploy/demo/gallery.json b/deploy/demo/gallery.json new file mode 100644 index 0000000..1b5810e --- /dev/null +++ b/deploy/demo/gallery.json @@ -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//. 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"] + } + ] +} diff --git a/scripts/deploy/build-demo.mjs b/scripts/deploy/build-demo.mjs new file mode 100644 index 0000000..d3ae1f7 --- /dev/null +++ b/scripts/deploy/build-demo.mjs @@ -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 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/// 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(); diff --git a/scripts/deploy/lib/cdn-store.mjs b/scripts/deploy/lib/cdn-store.mjs new file mode 100644 index 0000000..f298b2d --- /dev/null +++ b/scripts/deploy/lib/cdn-store.mjs @@ -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, + }); +} diff --git a/scripts/deploy/publish-content.mjs b/scripts/deploy/publish-content.mjs new file mode 100644 index 0000000..e545c7e --- /dev/null +++ b/scripts/deploy/publish-content.mjs @@ -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//. +// 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// snapshot is immutable; the app pins +// content//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 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(); diff --git a/scripts/deploy/publish-wasm.mjs b/scripts/deploy/publish-wasm.mjs new file mode 100644 index 0000000..fc679a1 --- /dev/null +++ b/scripts/deploy/publish-wasm.mjs @@ -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. `` 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-.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 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- 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(); diff --git a/web/standalone/.env.example b/web/standalone/.env.example index 9f244e2..b9eea48 100644 --- a/web/standalone/.env.example +++ b/web/standalone/.env.example @@ -5,11 +5,27 @@ # local-folder loader on the home page. VITE_API_BASE_URL=http://localhost:3060 -# Where the WASM glue/artifacts are served from. SAME-ORIGIN "/wasm" is required -# (KiCad WASM pthread workers cannot be created cross-origin). On `dev` the -# artifacts are symlinked into public/wasm and served by Vite at /wasm. For prod, -# set an absolute URL whose origin also satisfies the COEP/COOP rules. -VITE_WASM_ASSET_BASE_URL=/wasm +# Where PROJECTS come from: "remote" (default — the @pcbjam/shared backend above) +# or "static" (a read-only example gallery served from a CDN, NO backend — the +# demo.pcbjam.com mode; editor saves download to local). For "static" you must +# also set VITE_PROJECT_MANIFEST_URL. +# 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///) 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: # /tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere. diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 92e18aa..3557eb1 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -14,11 +14,11 @@ import { import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"; import { libsSourceConfig, - WASM_ASSET_BASE_URL, yjsProviderConfig, type DocSource, } from "@/lib/config"; import { bootKicadTool } from "@/wasm/boot"; +import { resolveWasmBase } from "@/wasm/wasm-assets"; import { LIB_BUSY_EVENT, LIB_ERROR_EVENT, @@ -537,7 +537,8 @@ export function WasmTool({ * to "api" (plain fetch + open). Local-folder sessions don't pass this. */ 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; }) { const containerRef = React.useRef(null); @@ -556,7 +557,6 @@ export function WasmTool({ // Last lib error (e.g. a backend 404 on open), shown as a dismissible toast. const [libError, setLibError] = React.useState(null); - const base = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, ""); const append = React.useCallback( (msg: string) => setLogs((prev) => [...prev.slice(-800), msg]), [], @@ -644,6 +644,9 @@ export function WasmTool({ void (async () => { 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({ tool, base, @@ -773,7 +776,7 @@ export function WasmTool({ // Boot is one-shot per mount; deps intentionally exclude files/targetPath so // they don't retrigger a (rejected) second boot. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tool, slug, base, append]); + }, [tool, slug, assetBaseUrl, append]); return (
diff --git a/web/standalone/src/lib/api.ts b/web/standalone/src/lib/api.ts index 6905341..5fa41b2 100644 --- a/web/standalone/src/lib/api.ts +++ b/web/standalone/src/lib/api.ts @@ -1,47 +1,34 @@ -import { - contract, - type DriftReportBody, - type Lib, - type Project, - type ProjectWithFiles, -} from "@pcbjam/shared"; -import { initClient } from "@ts-rest/core"; +import type { DriftReportBody, Lib } from "@pcbjam/shared"; 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 - * backend (enumerate, get file tree, stream bytes) and writes back exactly one - * thing: the bytes of a file the user explicitly saved in the editor (see - * uploadFileBytes). Project management (create/delete/bulk upload) stays in the - * closed application that hosts this editor. + * Project/file reads go through the active PROJECT SOURCE (lib/project-source.ts): + * the @pcbjam/shared REST backend, or the read-only static gallery (demo mode). + * Libraries + collab drift reporting are backend-only and stay on the contract + * client here. */ -export const client = initClient(contract, { - baseUrl: API_BASE_URL, - baseHeaders: {}, -}); export function useProjects() { return useQuery({ queryKey: ["projects"], - queryFn: async (): Promise => { - const res = await client.listProjects(); - if (res.status !== 200) throw new Error("failed to list projects"); - return res.body; - }, + queryFn: () => projectSource().listProjects(), }); } /** - * Libraries the backend serves, optionally filtered to a kind ("symbol" | - * "footprint"). Origins are kind-filtered server-side; user libs are - * kind-agnostic and always returned. Mirrors `useProjects` — read-only listing - * for the home page; the editor consumes libs over its own WASM bridge. + * Libraries the backend serves, optionally filtered to a kind. In static (no + * backend) mode there are none, so we short-circuit to an empty list rather than + * fire a doomed request. */ export function useLibs(kind?: "symbol" | "footprint") { return useQuery({ queryKey: ["libs", kind ?? "all"], queryFn: async (): Promise => { + if (PROJECT_SOURCE_KIND === "static") return []; const res = await client.listLibs({ query: { kind } }); if (res.status !== 200) throw new Error("failed to list libraries"); return res.body; @@ -52,35 +39,38 @@ export function useLibs(kind?: "symbol" | "footprint") { export function useProject(slug: string) { return useQuery({ queryKey: ["project", slug], - queryFn: async (): Promise => { - 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; - }, + queryFn: () => projectSource().getProject(slug), }); } -// --- raw file-byte download (streamed binary, not a ts-rest endpoint) --- - -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( +/** File bytes from the active source (backend stream, or the static CDN gallery). */ +export function fetchFileBytes( slug: string, relPath: string, ): Promise { - const res = await fetch(fileBytesUrl(slug, relPath)); - if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`); - return new Uint8Array(await res.arrayBuffer()); + return projectSource().fetchFileBytes(slug, relPath); } -// --- 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 { + 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). @@ -109,24 +99,3 @@ export function reportDriftBeacon(slug: string, body: DriftReportBody): void { } 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 { - 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}`); -} diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index f56e9cd..6412189 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -1,12 +1,44 @@ export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3050"; -// Default is SAME-ORIGIN ("/wasm", served from public/wasm by Vite). KiCad WASM -// pthread workers cannot be created cross-origin, so dev must serve same-origin. -// Override with an absolute URL (e.g. a CDN) only if that origin is configured -// to also satisfy the worker/COEP constraints. -export const WASM_ASSET_BASE_URL = - import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm"; +// Where the KiCad WASM artifacts are served from (no trailing slash). +// dev / same-origin: "/wasm" (flat layout, served from public/wasm by Vite). +// prod CDN: VITE_WASM_ROOT, e.g. "https://cdn.pcbjam.com/wasm". +// A cross-origin CDN works because boot.ts loads the pthread worker through a +// same-origin blob shim (see wasm/boot.ts) and the CDN sets CORP/ACAO. +// 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///) 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 { remoteLibsSource } from "@/wasm/libs/remote-source"; diff --git a/web/standalone/src/lib/contract-client.ts b/web/standalone/src/lib/contract-client.ts new file mode 100644 index 0000000..c56f963 --- /dev/null +++ b/web/standalone/src/lib/contract-client.ts @@ -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: {}, +}); diff --git a/web/standalone/src/lib/project-source.test.ts b/web/standalone/src/lib/project-source.test.ts new file mode 100644 index 0000000..8f5f451 --- /dev/null +++ b/web/standalone/src/lib/project-source.test.ts @@ -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 // 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" }); + }); +}); diff --git a/web/standalone/src/lib/project-source.ts b/web/standalone/src/lib/project-source.ts new file mode 100644 index 0000000..e07c5fa --- /dev/null +++ b/web/standalone/src/lib/project-source.ts @@ -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; + getProject(slug: string): Promise; + fetchFileBytes(slug: string, relPath: string): Promise; + /** Present only on writable sources; absent ⇒ read-only (download on save). */ + uploadFileBytes?( + slug: string, + relPath: string, + bytes: Uint8Array, + ): Promise; +} + +// --- 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 //. + const baseDir = manifestUrl.replace(/\/[^/]*$/, ""); + let manifestP: Promise | 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; +} diff --git a/web/standalone/src/pages/HomePage.tsx b/web/standalone/src/pages/HomePage.tsx index ae1766a..7fded3e 100644 --- a/web/standalone/src/pages/HomePage.tsx +++ b/web/standalone/src/pages/HomePage.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import type { Lib, Tool } from "@pcbjam/shared"; import { FolderOpen, Library, Loader2, Package } from "lucide-react"; import { useLibs, useProjects } from "@/lib/api"; +import { PROJECT_SOURCE_KIND } from "@/lib/config"; import { localFileLibsSource } from "@/wasm/libs/local-file-source"; import type { LibsSource } from "@/wasm/libs/source"; import { downloadBytes } from "@/lib/download"; @@ -101,6 +102,9 @@ function buildLocalProject(fileList: FileList): LocalProject { } 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 symbolLibs = useLibs("symbol"); const footprintLibs = useLibs("footprint"); @@ -190,8 +194,9 @@ export function HomePage() {

PCBJam

- Open KiCad files in the browser — from a backend, or straight from a - local folder. + {staticMode + ? "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."}

{/* --- Local folder --- */} @@ -243,9 +248,11 @@ export function HomePage() { setLaunchedTool({ tool })} /> - {/* --- Backend projects --- */} + {/* --- Projects (backend, or the static example gallery) --- */}
-

Projects from the backend

+

+ {staticMode ? "Example projects" : "Projects from the backend"} +

{isLoading && (

loading… @@ -253,8 +260,9 @@ export function HomePage() { )} {error && (

- No backend reachable ({(error as Error).message}). Use a local folder - above, or configure VITE_API_BASE_URL. + {staticMode + ? `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.`}

)}
@@ -274,30 +282,32 @@ export function HomePage() { ))} {projects && projects.length === 0 && (
- The backend has no projects. + {staticMode ? "No example projects." : "The backend has no projects."}
)}
- {/* --- Backend libraries --- */} -
-

Libraries from the backend

-
- } - label="Symbols" - query={symbolLibs} - tool="symbol_editor" - /> - } - label="Footprints" - query={footprintLibs} - tool="footprint_editor" - /> -
-
+ {/* --- Backend libraries (hidden in the no-backend static demo) --- */} + {!staticMode && ( +
+

Libraries from the backend

+
+ } + label="Symbols" + query={symbolLibs} + tool="symbol_editor" + /> + } + label="Footprints" + query={footprintLibs} + tool="footprint_editor" + /> +
+
+ )}
); } diff --git a/web/standalone/src/pages/ToolPage.tsx b/web/standalone/src/pages/ToolPage.tsx index 3d61753..77766e3 100644 --- a/web/standalone/src/pages/ToolPage.tsx +++ b/web/standalone/src/pages/ToolPage.tsx @@ -43,6 +43,8 @@ export function ToolPage() { // PreflightGate runs the device-capability check; on a fatal mismatch it blocks // 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 ( /`, so the .wasm and the * pthread worker script are fetched from the asset dir regardless of the * SPA route the user is on. - * - `mainScriptUrlOrBlob` pins the pthread worker to `/.js` - * (same-origin — required: KiCad's pthreads cannot spawn cross-origin). + * - `mainScriptUrlOrBlob` pins the pthread worker to `/.js`. For a + * 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()` is illegal). * * 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 @@ -91,6 +93,27 @@ function loadScript(src: string): Promise { }); } +/** + * The pthread worker "script" passed as `Module.mainScriptUrlOrBlob`. KiCad's + * pthreads spawn CLASSIC workers via `new Worker(...)` (see `.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()` + * 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 { const { tool, base, container, log, onStatus, onAbort, libsSource } = opts; const w = window as ToolWindow; @@ -276,8 +299,9 @@ async function doBoot(opts: BootOptions): Promise { }, // Resolve wasm + pthread worker against the asset base, not the SPA route. locateFile: (path: string) => `${base}/${path}`, - // Pin the pthread worker script (must be same-origin). - mainScriptUrlOrBlob: `${base}/${tool}.js`, + // Pin the pthread worker script. Same-origin → direct URL; cross-origin CDN + // → 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/.html): diff --git a/web/standalone/src/wasm/wasm-assets.test.ts b/web/standalone/src/wasm/wasm-assets.test.ts new file mode 100644 index 0000000..c854dcd --- /dev/null +++ b/web/standalone/src/wasm/wasm-assets.test.ts @@ -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/); + }); +}); diff --git a/web/standalone/src/wasm/wasm-assets.ts b/web/standalone/src/wasm/wasm-assets.ts new file mode 100644 index 0000000..a91666d --- /dev/null +++ b/web/standalone/src/wasm/wasm-assets.ts @@ -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///` (`.wasm`, `.js`, `wx.js`, + * `wx-dom.js`, `images.tar.gz`). A per-release `manifest-.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; +} + +let manifestPromise: Promise | null = null; + +function loadManifest(): Promise { + 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//` from `manifest-.json`. + */ +export async function resolveWasmBase( + tool: Tool, + override?: string, +): Promise { + 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}`; +}