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