Library warm-up and the per-item fallback fat-load now run through a
shared asyncMap helper (counting semaphore, 8 in flight): the CDN
presync's hand-rolled 6-worker pool is replaced and the previously
fully-serial listItems+getItemBody fallback is parallelized (order
preserved).
The load-screen sync line stops flashing: progress reports on lib
COMPLETION only (no "current lib" — with 8 in flight there isn't one),
the label is a fixed prefix ("Syncing symbol libraries — ") and the
counter is space-padded to the total's width in font-mono +
whitespace-pre, so 2→3 digit growth doesn't shift the text. Same
padding for the fat-load overlay's "x / y libraries" line.
Verified in demo mode against the live R2 CDN: 7-8 overlapping
manifest/bundle requests, 222 cold symbol libs in a few seconds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgThWXtdvrYLK47EDFoGdq
210 lines
7.9 KiB
TypeScript
210 lines
7.9 KiB
TypeScript
import { SyncStack, type SyncStackOptions } from "@pcbjam/sync-client";
|
|
import { asyncMap } from "@/lib/async-map";
|
|
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)
|
|
* fp-index.json footprint index: per-lib [name, uniquePadCount]
|
|
* <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;
|
|
let fpIndexP: Promise<string | null> | null = null;
|
|
const fetchManifest = async (): Promise<CdnLibsManifest> => {
|
|
// Retry with backoff. Firefox can fail a cross-origin fetch issued in the
|
|
// first moments after navigation under COEP (the lazy path runs seconds
|
|
// later and succeeds); a short retry rides past that window. The pre-sync
|
|
// warm-up, which fires this earliest, is what surfaced it.
|
|
let lastErr: unknown;
|
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
if (attempt > 0) await new Promise((r) => setTimeout(r, 200 * attempt));
|
|
try {
|
|
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;
|
|
} catch (e) {
|
|
lastErr = e;
|
|
}
|
|
}
|
|
throw lastErr;
|
|
};
|
|
const loadManifest = (): Promise<CdnLibsManifest> => {
|
|
if (!manifestP) {
|
|
// NEVER cache a rejection: a transient failure must not poison every later
|
|
// listLibs/getItemBody — the next call retries from scratch.
|
|
manifestP = fetchManifest().catch((e) => {
|
|
manifestP = null;
|
|
throw e;
|
|
});
|
|
}
|
|
return manifestP;
|
|
};
|
|
|
|
// 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;
|
|
})().catch((e) => {
|
|
// Don't cache a failed open (e.g. an early-boot fetch blip) — the lazy
|
|
// path must be able to retry this lib instead of inheriting the failure.
|
|
stacks.delete(libId);
|
|
throw e;
|
|
});
|
|
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",
|
|
itemCount: l.itemCount,
|
|
}));
|
|
},
|
|
async listItems(libId: string): Promise<LibItemInfo[]> {
|
|
const stack = await openStack(libId);
|
|
return (await stack.list()).map((e) => splitPath(e.path));
|
|
},
|
|
async presync(opts): Promise<void> {
|
|
const { kind, concurrency = 8, onProgress, signal } = opts ?? {};
|
|
let m: CdnLibsManifest;
|
|
try {
|
|
m = await loadManifest();
|
|
} catch {
|
|
// Best-effort warm-up: a manifest hiccup here (e.g. the early-boot
|
|
// Firefox/COEP fetch blip) is non-fatal — skip quietly and let the lazy
|
|
// path fetch on demand (loadManifest no longer caches the failure).
|
|
return;
|
|
}
|
|
const libs = m.libs.filter((l) => !kind || l.kind === kind);
|
|
const total = libs.length;
|
|
let done = 0;
|
|
onProgress?.({ done, total, current: "" });
|
|
// Concurrency-limited: each openStack cold-fetches one bundle into IDB
|
|
// (warm ⇒ a cheap manifest diff). Tolerate per-lib failures so one bad lib
|
|
// doesn't abort the warm-up. Progress is reported on COMPLETION only —
|
|
// with several libs in flight there is no single "current" one.
|
|
await asyncMap(
|
|
libs,
|
|
async (lib) => {
|
|
if (signal?.aborted) return;
|
|
try {
|
|
await openStack(lib.id);
|
|
} catch {
|
|
// best-effort: the lib still loads lazily on demand
|
|
}
|
|
if (signal?.aborted) return;
|
|
onProgress?.({ done: ++done, total, current: lib.name });
|
|
},
|
|
concurrency,
|
|
);
|
|
},
|
|
async getAllItems(
|
|
libId: string,
|
|
): Promise<Array<{ kind: string; name: string; body: Uint8Array }>> {
|
|
// One bulk merged read of the whole lib (the IDB cache after cold bundle).
|
|
// "Copy as-is": hand back the raw IDB bytes (no TextDecoder) so the provider
|
|
// frames them and the bridge memcpy's them into wasm with no string/JSON
|
|
// detour — the plugin slices them straight out of the wasm heap.
|
|
const stack = await openStack(libId);
|
|
return [...(await stack.readAll())].map(([path, bytes]) => {
|
|
const { kind, name } = splitPath(path);
|
|
return { kind, name, body: bytes };
|
|
});
|
|
},
|
|
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;
|
|
},
|
|
async getFpIndex(): Promise<string | null> {
|
|
// Published next to the top manifest (immutable, ~100 KB compressed) —
|
|
// passed through as raw text; the WASM side parses it once and caches.
|
|
// A missing index (tag predates fp-index publishing, or a fetch error)
|
|
// resolves null and the editor falls back to per-lib lazy loads; null is
|
|
// NOT cached so a transient failure retries on the next chooser use.
|
|
if (!fpIndexP) {
|
|
fpIndexP = (async () => {
|
|
const r = await fetchImpl(`${baseDir}/fp-index.json`);
|
|
if (r.status === 404) return null;
|
|
if (!r.ok) throw new Error(`cdn fp-index ${r.status}`);
|
|
return await r.text();
|
|
})().catch(() => {
|
|
fpIndexP = null;
|
|
return null;
|
|
});
|
|
}
|
|
return fpIndexP;
|
|
},
|
|
// 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) };
|
|
}
|