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,
|
tag: null,
|
||||||
cdn: "https://cdn.pcbjam.com",
|
cdn: "https://cdn.pcbjam.com",
|
||||||
repo: "https://github.com/emergence-engineering/pcbjam",
|
repo: "https://github.com/emergence-engineering/pcbjam",
|
||||||
|
libTag: null,
|
||||||
};
|
};
|
||||||
for (let i = 2; i < argv.length; i++) {
|
for (let i = 2; i < argv.length; i++) {
|
||||||
const next = () => argv[++i];
|
const next = () => argv[++i];
|
||||||
|
|
@ -33,6 +34,9 @@ function parseArgs(argv) {
|
||||||
case "--tag": a.tag = next(); break;
|
case "--tag": a.tag = next(); break;
|
||||||
case "--cdn": a.cdn = next(); break;
|
case "--cdn": a.cdn = next(); break;
|
||||||
case "--repo": a.repo = 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]}`);
|
default: throw new Error(`unknown arg: ${argv[i]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,8 +78,14 @@ function main() {
|
||||||
// Loaded folders import into a browser-local (IndexedDB) project — editable,
|
// Loaded folders import into a browser-local (IndexedDB) project — editable,
|
||||||
// persistent, exported via Download .zip — layered over the gallery.
|
// persistent, exported via Download .zip — layered over the gallery.
|
||||||
VITE_LOCAL_PROJECTS: "idb",
|
VITE_LOCAL_PROJECTS: "idb",
|
||||||
// Built-in offline symbols (no backend); cross-tab collab only.
|
// Libraries: the full KiCad set from versioned CDN static origins when a
|
||||||
VITE_LIBS_SOURCE: "static",
|
// --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",
|
VITE_YJS_PROVIDER: "broadcastchannel",
|
||||||
// Build identity for the version badge. The commit is the GPLv3
|
// Build identity for the version badge. The commit is the GPLv3
|
||||||
// corresponding-source pointer (pins the kicad + wxwidgets submodules).
|
// 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_WASM_MANIFEST=${env.VITE_WASM_MANIFEST}`);
|
||||||
console.log(` VITE_PROJECT_MANIFEST_URL=${env.VITE_PROJECT_MANIFEST_URL}`);
|
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_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
|
// 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.
|
// 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.meta.env.VITE_LOCAL_PROJECTS === "idb";
|
||||||
|
|
||||||
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||||
|
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
|
||||||
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
||||||
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
|
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
|
||||||
import type { LibsSource } from "@/wasm/libs/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";
|
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 {
|
export function libsSourceConfig(projectId?: string): LibsSource | null {
|
||||||
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
|
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
|
||||||
// "local" is the placeholder id for launches with no real backend project
|
// "local" is the placeholder id for launches with no real backend project
|
||||||
|
|
@ -150,7 +158,11 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
|
||||||
? null
|
? null
|
||||||
: kind === "static"
|
: kind === "static"
|
||||||
? staticLibsSource()
|
? 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
|
// 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
|
// 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;
|
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. */
|
/** "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;
|
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. */
|
/** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */
|
||||||
readonly VITE_YJS_PROVIDER?: string;
|
readonly VITE_YJS_PROVIDER?: string;
|
||||||
/** Host/URL for network collab providers (partykit, hocuspocus). */
|
/** 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