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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
web/standalone/src/vite-env.d.ts
vendored
9
web/standalone/src/vite-env.d.ts
vendored
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
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