diff --git a/scripts/deploy/build-demo.mjs b/scripts/deploy/build-demo.mjs index 217f1e3..f7d5741 100644 --- a/scripts/deploy/build-demo.mjs +++ b/scripts/deploy/build-demo.mjs @@ -26,6 +26,7 @@ function parseArgs(argv) { tag: null, cdn: "https://cdn.pcbjam.com", repo: "https://github.com/emergence-engineering/pcbjam", + libTag: null, }; for (let i = 2; i < argv.length; i++) { const next = () => argv[++i]; @@ -33,6 +34,9 @@ function parseArgs(argv) { case "--tag": a.tag = next(); break; case "--cdn": a.cdn = next(); break; case "--repo": a.repo = next(); break; + // KiCad library snapshot tag (published once to libs/kicad//). + // Omitted ⇒ the offline built-in example symbols (back-compat). + case "--lib-tag": a.libTag = next(); break; default: throw new Error(`unknown arg: ${argv[i]}`); } } @@ -74,8 +78,14 @@ function main() { // Loaded folders import into a browser-local (IndexedDB) project — editable, // persistent, exported via Download .zip — layered over the gallery. VITE_LOCAL_PROJECTS: "idb", - // Built-in offline symbols (no backend); cross-tab collab only. - VITE_LIBS_SOURCE: "static", + // Libraries: the full KiCad set from versioned CDN static origins when a + // --lib-tag is given (read-only, IDB-cached); else built-in offline symbols. + ...(a.libTag + ? { + VITE_LIBS_SOURCE: "cdn", + VITE_LIBS_MANIFEST_URL: `${a.cdn}/libs/kicad/${a.libTag}/manifest.json`, + } + : { VITE_LIBS_SOURCE: "static" }), VITE_YJS_PROVIDER: "broadcastchannel", // Build identity for the version badge. The commit is the GPLv3 // corresponding-source pointer (pins the kicad + wxwidgets submodules). @@ -89,6 +99,7 @@ function main() { console.log(` VITE_WASM_MANIFEST=${env.VITE_WASM_MANIFEST}`); console.log(` VITE_PROJECT_MANIFEST_URL=${env.VITE_PROJECT_MANIFEST_URL}`); console.log(` VITE_APP_TAG=${env.VITE_APP_TAG} VITE_GIT_SHA=${env.VITE_GIT_SHA || "(none)"}`); + console.log(` VITE_LIBS_SOURCE=${env.VITE_LIBS_SOURCE}${env.VITE_LIBS_MANIFEST_URL ? ` (${env.VITE_LIBS_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. diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index 2b575a1..d3b7bf3 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -72,6 +72,7 @@ export const LOCAL_PROJECTS_ENABLED = import.meta.env.VITE_LOCAL_PROJECTS === "idb"; import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; +import { cdnLibsSource } from "@/wasm/libs/cdn-source"; import { remoteLibsSource } from "@/wasm/libs/remote-source"; import { scopedLibsSource } from "@/wasm/libs/scoped-source"; import type { LibsSource } from "@/wasm/libs/source"; @@ -137,6 +138,13 @@ export function libsOwner(): string { return import.meta.env.VITE_LIBS_OWNER ?? "local-user"; } +/** Full URL of the CDN libs top manifest (required for VITE_LIBS_SOURCE=cdn), + * e.g. https://cdn.pcbjam.com/libs/kicad/9.0.0/manifest.json. The full default + * KiCad symbol+footprint set, served read-only as version-pinned static origins + * (IDB-cached). See wasm/libs/cdn-source.ts + docs/features/r2-idb-sync. */ +export const CDN_LIBS_MANIFEST_URL = + import.meta.env.VITE_LIBS_MANIFEST_URL || null; + export function libsSourceConfig(projectId?: string): LibsSource | null { const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote"; // "local" is the placeholder id for launches with no real backend project @@ -150,7 +158,11 @@ export function libsSourceConfig(projectId?: string): LibsSource | null { ? null : kind === "static" ? staticLibsSource() - : remoteLibsSource(API_BASE_URL, libsOwner(), project); + : kind === "cdn" + ? CDN_LIBS_MANIFEST_URL + ? cdnLibsSource(CDN_LIBS_MANIFEST_URL) + : staticLibsSource() // misconfigured cdn ⇒ offline fallback + : remoteLibsSource(API_BASE_URL, libsOwner(), project); // 0004-A spike: `?libwrite=1` adds one in-memory writable user SYMBOL lib so the // editor save path works with no backend (a dev/test aid). The real remote diff --git a/web/standalone/src/vite-env.d.ts b/web/standalone/src/vite-env.d.ts index cf020db..54f71a1 100644 --- a/web/standalone/src/vite-env.d.ts +++ b/web/standalone/src/vite-env.d.ts @@ -20,6 +20,10 @@ interface ImportMetaEnv { readonly VITE_PROJECT_MANIFEST_URL?: string; /** "idb" ⇒ loaded folders import into a browser-local (IndexedDB) project with its own URL; otherwise the in-page File System Access flow. */ readonly VITE_LOCAL_PROJECTS?: string; + /** Library source: "remote" (default backend) | "static" (offline examples) | "cdn" (full KiCad set from CDN static origins) | "synced" | "off". */ + readonly VITE_LIBS_SOURCE?: string; + /** CDN libs top-manifest URL (required when VITE_LIBS_SOURCE=cdn), e.g. https://cdn.pcbjam.com/libs/kicad/9.0.0/manifest.json. */ + readonly VITE_LIBS_MANIFEST_URL?: string; /** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */ readonly VITE_YJS_PROVIDER?: string; /** Host/URL for network collab providers (partykit, hocuspocus). */ diff --git a/web/standalone/src/wasm/libs/cdn-source.test.ts b/web/standalone/src/wasm/libs/cdn-source.test.ts new file mode 100644 index 0000000..d29be57 --- /dev/null +++ b/web/standalone/src/wasm/libs/cdn-source.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { encodeBundle, sha256Hex, type SyncManifest } from "@pcbjam/shared"; +import { memStore } from "@pcbjam/sync-client"; +import { cdnLibsSource } from "./cdn-source"; + +const MANIFEST_URL = "https://cdn.test/libs/kicad/9.0.0/manifest.json"; +const BASE = "https://cdn.test/libs/kicad/9.0.0"; + +const enc = new TextEncoder(); + +/** Build a static-origin snapshot (per-lib manifest + bundle) the way + * publish-libs will, using the REAL wire codecs — so this pins the format. */ +async function makeLib(items: Record) { + const bodies = Object.entries(items).map( + ([path, text]): [string, Uint8Array] => [path, enc.encode(text)], + ); + const entries: SyncManifest["entries"] = {}; + for (const [path, body] of bodies) { + entries[path] = { hash: await sha256Hex(body), size: body.length, mtime: 0 }; + } + const manifest: SyncManifest = { version: 1, entries }; + return { manifest, bundle: encodeBundle(manifest, bodies) }; +} + +async function fakeCdn() { + const device = await makeLib({ + "symbol/R": "(kicad_symbol_lib (symbol R))", + "symbol/C": "(kicad_symbol_lib (symbol C))", + }); + const resistors = await makeLib({ + "footprint/R_0402_1005Metric": "(footprint R_0402)", + }); + const top = { + schema: 1, + tag: "9.0.0", + libs: [ + { id: "Device", name: "Device", kind: "symbol", itemCount: 2 }, + { id: "Resistor_SMD", name: "Resistor_SMD", kind: "footprint", itemCount: 1 }, + ], + }; + const json = (obj: unknown) => ({ ok: true, json: async () => obj }); + const bin = (bytes: Uint8Array) => ({ + ok: true, + arrayBuffer: async () => bytes.buffer, + }); + const fetchImpl = (async (url: string) => { + if (url === MANIFEST_URL) return json(top); + if (url === `${BASE}/Device/manifest`) return json(device.manifest); + if (url === `${BASE}/Device/bundle`) return bin(device.bundle); + if (url === `${BASE}/Resistor_SMD/manifest`) return json(resistors.manifest); + if (url === `${BASE}/Resistor_SMD/bundle`) return bin(resistors.bundle); + return { ok: false, status: 404 }; + }) as unknown as typeof fetch; + return cdnLibsSource(MANIFEST_URL, { + fetchImpl, + storeFactory: () => memStore(), + }); +} + +describe("cdn libs source", () => { + it("lists libs from the top manifest, filtered by kind", async () => { + const src = await fakeCdn(); + expect((await src.listLibs()).map((l) => l.id)).toEqual([ + "Device", + "Resistor_SMD", + ]); + expect((await src.listLibs("symbol")).map((l) => l.id)).toEqual(["Device"]); + expect((await src.listLibs("footprint")).map((l) => l.id)).toEqual([ + "Resistor_SMD", + ]); + }); + + it("lists a lib's items from one cold bundle fetch", async () => { + const src = await fakeCdn(); + const items = await src.listItems("Device"); + expect(items.sort((a, b) => a.name.localeCompare(b.name))).toEqual([ + { kind: "symbol", name: "C" }, + { kind: "symbol", name: "R" }, + ]); + }); + + it("returns a self-contained item body by kind/name", async () => { + const src = await fakeCdn(); + expect(await src.getItemBody("Device", "symbol", "R")).toBe( + "(kicad_symbol_lib (symbol R))", + ); + expect( + await src.getItemBody("Resistor_SMD", "footprint", "R_0402_1005Metric"), + ).toBe("(footprint R_0402)"); + expect(await src.getItemBody("Device", "symbol", "Nope")).toBeNull(); + }); + + it("is read-only (no save path)", async () => { + const src = await fakeCdn(); + expect(src.saveItemBody).toBeUndefined(); + }); +}); diff --git a/web/standalone/src/wasm/libs/cdn-source.ts b/web/standalone/src/wasm/libs/cdn-source.ts new file mode 100644 index 0000000..ea43f16 --- /dev/null +++ b/web/standalone/src/wasm/libs/cdn-source.ts @@ -0,0 +1,111 @@ +import { SyncStack, type SyncStackOptions } from "@pcbjam/sync-client"; +import type { LibInfo, LibItemInfo, LibsSource } from "./source"; + +/** + * A read-only, no-backend `LibsSource` that serves the FULL default KiCad symbol + * + footprint set from immutable CDN snapshots (the demo's libs). Each lib is a + * version-pinned r2-idb-sync **static origin** (docs/features/r2-idb-sync): the + * editor opens it as a one-layer `SyncStack` that fetches one `bundle` cold and + * serves list/get from a per-lib IndexedDB cache after that (0 fetches warm, + * works offline). No resolve endpoint — the layer descriptor is built locally + * from the lib id + tag, so there's no backend in the loop. + * + * Layout under the manifest's directory (`/libs/kicad//`): + * manifest.json top index: every lib (id, name, kind, itemCount) + * /manifest per-lib SyncManifest (GET .../manifest) + * /bundle per-lib bundle: manifest + all bodies (cold init) + * Item paths inside a lib follow the `"/"` scheme. + */ + +interface CdnLibEntry { + id: string; + name: string; + kind: "symbol" | "footprint"; + itemCount?: number; + description?: string | null; +} +interface CdnLibsManifest { + schema: number; + tag: string; + libs: CdnLibEntry[]; +} + +export function cdnLibsSource( + manifestUrl: string, + // Test seam: inject fetch + an in-memory store (SyncStack defaults to real + // fetch + IndexedDB). Production passes nothing. + opts?: Pick, +): LibsSource { + const baseDir = manifestUrl.replace(/\/[^/]*$/, ""); // /libs/kicad/ + const fetchImpl = opts?.fetchImpl ?? fetch; + + let manifestP: Promise | null = null; + const loadManifest = () => + (manifestP ??= (async () => { + const r = await fetchImpl(manifestUrl, { cache: "no-store" }); + if (!r.ok) throw new Error(`cdn libs manifest ${r.status}: ${manifestUrl}`); + return (await r.json()) as CdnLibsManifest; + })()); + + // One lazily-opened SyncStack per lib (its IDB store is keyed by namespace, so + // a lib is cached once and reused across opens). + const stacks = new Map>(); + const openStack = (libId: string): Promise => { + let p = stacks.get(libId); + if (!p) { + p = (async () => { + const m = await loadManifest(); + const stack = new SyncStack({ + layers: [ + { + namespace: `kicad:${m.tag}:${libId}`, + kind: "static", + url: `${baseDir}/${encodeURIComponent(libId)}`, + }, + ], + ...opts, + }); + await stack.open(); + return stack; + })(); + stacks.set(libId, p); + } + return p; + }; + + return { + async listLibs(kind?: string): Promise { + const m = await loadManifest(); + return m.libs + .filter((l) => !kind || l.kind === kind) + .map((l) => ({ + id: l.id, + name: l.name, + description: l.description ?? null, + type: "origin", + })); + }, + async listItems(libId: string): Promise { + const stack = await openStack(libId); + return (await stack.list()).map((e) => splitPath(e.path)); + }, + async getItemBody( + libId: string, + kind: string, + name: string, + ): Promise { + const stack = await openStack(libId); + const bytes = await stack.read(`${kind}/${name}`); + return bytes ? new TextDecoder().decode(bytes) : null; + }, + // Read-only: no saveItemBody / createLib (the demo's default libs are fixed). + }; +} + +/** Decode a `"/"` namespace path into editor item terms. */ +function splitPath(path: string): LibItemInfo { + const i = path.indexOf("/"); + return i < 0 + ? { kind: path, name: "" } + : { kind: path.slice(0, i), name: path.slice(i + 1) }; +}