feat(standalone): cdn libs source (C1.1) — full KiCad set from R2 static origins

Backendless, read-only LibsSource that serves the full default symbol+footprint set from versioned immutable CDN snapshots. Each lib is an r2-idb-sync static origin opened as a one-layer SyncStack (1 bundle cold, 0 fetches warm, IDB-cached, offline) — the layer descriptor is built locally from lib id + tag, no resolve endpoint in the loop.

Wired via VITE_LIBS_SOURCE=cdn + VITE_LIBS_MANIFEST_URL; build-demo gains --lib-tag (points the demo at libs/kicad/<tag>/manifest.json; omitted keeps offline static). Unit-tested against a fake CDN built with the real encodeBundle/sha256Hex codecs, pinning the publish format.

Next: publish-libs.mjs (C1.2) emits this format over the full set; CI keyed by lib tag (C1.3).

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

View file

@ -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/<libTag>/).
// 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.

View file

@ -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

View file

@ -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). */

View file

@ -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<string, string>) {
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();
});
});

View file

@ -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 (`<cdn>/libs/kicad/<tag>/`):
* manifest.json top index: every lib (id, name, kind, itemCount)
* <libId>/manifest per-lib SyncManifest (GET .../manifest)
* <libId>/bundle per-lib bundle: manifest + all bodies (cold init)
* Item paths inside a lib follow the `"<kind>/<name>"` 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<SyncStackOptions, "fetchImpl" | "storeFactory">,
): LibsSource {
const baseDir = manifestUrl.replace(/\/[^/]*$/, ""); // <cdn>/libs/kicad/<tag>
const fetchImpl = opts?.fetchImpl ?? fetch;
let manifestP: Promise<CdnLibsManifest> | 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<string, Promise<SyncStack>>();
const openStack = (libId: string): Promise<SyncStack> => {
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<LibInfo[]> {
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<LibItemInfo[]> {
const stack = await openStack(libId);
return (await stack.list()).map((e) => splitPath(e.path));
},
async getItemBody(
libId: string,
kind: string,
name: string,
): Promise<string | null> {
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 `"<kind>/<name>"` 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) };
}