pcbjam/web/standalone/src/wasm/libs/cdn-source.ts
Gergő Törcsvári 157d60f6b8
feat(standalone): 8-way parallel lib presync + stable load counters
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
2026-07-07 21:09:23 +02:00

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) };
}