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:
parent
142e2d4e5e
commit
307a66dd77
5 changed files with 238 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
web/standalone/src/vite-env.d.ts
vendored
4
web/standalone/src/vite-env.d.ts
vendored
|
|
@ -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). */
|
||||
|
|
|
|||
97
web/standalone/src/wasm/libs/cdn-source.test.ts
Normal file
97
web/standalone/src/wasm/libs/cdn-source.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
111
web/standalone/src/wasm/libs/cdn-source.ts
Normal file
111
web/standalone/src/wasm/libs/cdn-source.ts
Normal 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) };
|
||||
}
|
||||
Loading…
Reference in a new issue