feat(demo): demo.pcbjam.com deploy — versioned WASM CDN + static gallery + tag CI

Cross-origin WASM CDN (cdn.pcbjam.com, R2): per-tool content-addressed,
immutable folders + a per-release runtime manifest (snapshot from registry).
boot.ts loads pthread workers cross-origin via a same-origin blob shim.
Static no-backend project source (demo gallery; Save downloads to local),
api.uploadFileBytes kept and config-gated. demo.pcbjam.com on Cloudflare Pages.
deploy-demo.yml (tag v*) snapshots WASM + content + builds + deploys;
publish-wasm.yml builds on Ubicloud. Design/spec docs live in pcbjam-private.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-18 17:09:40 +02:00
commit 5aae2a0d16
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
23 changed files with 1489 additions and 112 deletions

View file

@ -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/<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:
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.

View file

@ -14,11 +14,11 @@ import {
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import {
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<HTMLDivElement>(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<string | null>(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 (
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">

View file

@ -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<Project[]> => {
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<Lib[]> => {
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<ProjectWithFiles> => {
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<Uint8Array> {
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<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).
@ -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<void> {
const name = relPath.split("/").pop() ?? relPath;
const form = new FormData();
form.append(relPath, new File([bytes as BlobPart], name));
const res = await fetch(
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
{ method: "POST", body: form },
);
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
}

View file

@ -1,12 +1,44 @@
export const API_BASE_URL =
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/<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 { remoteLibsSource } from "@/wasm/libs/remote-source";

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import type { Lib, Tool } from "@pcbjam/shared";
import { 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() {
<div className="container max-w-3xl py-10">
<h1 className="text-2xl font-semibold tracking-tight">PCBJam</h1>
<p className="mb-8 text-sm text-muted-foreground">
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."}
</p>
{/* --- Local folder --- */}
@ -243,9 +248,11 @@ export function HomePage() {
<ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} />
</section>
{/* --- Backend projects --- */}
{/* --- Projects (backend, or the static example gallery) --- */}
<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 && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading…
@ -253,8 +260,9 @@ export function HomePage() {
)}
{error && (
<p className="text-sm text-muted-foreground">
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.`}
</p>
)}
<div className="divide-y rounded-lg border">
@ -274,30 +282,32 @@ export function HomePage() {
))}
{projects && projects.length === 0 && (
<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>
</section>
{/* --- Backend libraries --- */}
<section>
<h2 className="mb-3 text-lg font-medium">Libraries from the backend</h2>
<div className="space-y-3">
<LibGroup
icon={<Library size={16} />}
label="Symbols"
query={symbolLibs}
tool="symbol_editor"
/>
<LibGroup
icon={<Package size={16} />}
label="Footprints"
query={footprintLibs}
tool="footprint_editor"
/>
</div>
</section>
{/* --- Backend libraries (hidden in the no-backend static demo) --- */}
{!staticMode && (
<section>
<h2 className="mb-3 text-lg font-medium">Libraries from the backend</h2>
<div className="space-y-3">
<LibGroup
icon={<Library size={16} />}
label="Symbols"
query={symbolLibs}
tool="symbol_editor"
/>
<LibGroup
icon={<Package size={16} />}
label="Footprints"
query={footprintLibs}
tool="footprint_editor"
/>
</div>
</section>
)}
</div>
);
}

View file

@ -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 (
<PreflightGate>
<WasmTool

View file

@ -2,7 +2,16 @@
interface ImportMetaEnv {
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;
/** 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. */
readonly VITE_YJS_PROVIDER?: string;
/** Host/URL for network collab providers (partykit, hocuspocus). */

View file

@ -32,8 +32,10 @@ const DEFAULT_USER_LIB_NAME = "My Symbols";
* - `locateFile` is overridden to resolve `<base>/<file>`, so the .wasm and the
* pthread worker script are fetched from the asset dir regardless of the
* SPA route the user is on.
* - `mainScriptUrlOrBlob` pins the pthread worker to `<base>/<tool>.js`
* (same-origin — required: KiCad's pthreads cannot spawn cross-origin).
* - `mainScriptUrlOrBlob` pins the pthread worker to `<base>/<tool>.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(<cross-origin URL>)` 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<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> {
const { tool, base, container, log, onStatus, onAbort, libsSource } = opts;
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.
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/<tool>.html):

View file

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

View file

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