pcbjam/web/standalone/src/wasm/libs/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

420 lines
16 KiB
TypeScript

import { asyncMap } from "@/lib/async-map";
import { handleModel3dRequest } from "./models-bridge";
import { libIdFromUri, libUri } from "./uri";
/**
* The data a `LibsSource` provides. A source abstracts WHERE library data comes
* from (a remote backend over the shared contract, a static public snapshot, a
* local folder); the WASM-facing provider below is the same regardless.
*/
export interface LibInfo {
/** Opaque id used in the lib-table URI (/mnt/pcbjam/<id>). */
id: string;
/** Display nickname for the sym-lib-table row. */
name: string;
description?: string | null;
/** 'origin' | 'mirror' | 'user' — drives "ensure a user lib exists" at boot. */
type?: string;
/** Item count, when the source knows it cheaply (manifest/backend) — shown as a
* badge in the home-page library list. Omitted ⇒ no badge. */
itemCount?: number;
}
export interface LibItemInfo {
kind: string; // 'symbol' | 'footprint' | 'model3d'
name: string;
}
/** Per-lib progress for {@link LibsSource.presync} (drives the load screen). */
export interface LibPresyncProgress {
done: number;
total: number;
/** Display name of the lib currently being synced (one of the in-flight set). */
current: string;
}
export interface LibsSource {
/**
* Libraries to expose to the editor (one lib-table row each). `kind` (the
* current tool's item kind, "symbol" | "footprint") filters origins to those
* holding that kind; user libs are kind-agnostic containers, always listed.
* Omitted ⇒ all libs.
*/
listLibs(kind?: string): Promise<LibInfo[]>;
/** Items in a library (by lib id). */
listItems(libId: string): Promise<LibItemInfo[]>;
/**
* One self-contained item body (a complete `kicad_symbol_lib` s-expr), or
* null if absent. `kind` is 'symbol' for now.
*/
getItemBody(libId: string, kind: string, name: string): Promise<string | null>;
/**
* ALL items in a library with their bodies, in one shot — the "fat list" that
* lets the WASM plugin hydrate a whole library in a single bridge crossing
* instead of N per-item `get`s (see docs/features/libs/0011). Optional: sources
* that can't bulk-read omit it and the provider falls back to listItems + N
* getItemBody (the old slow path, kept working for the example backend).
*/
getAllItems?(
libId: string,
): Promise<Array<{ kind: string; name: string; body: Uint8Array }>>;
/**
* Pre-warm this source's per-lib caches (IndexedDB bundles) WITHOUT touching the
* WASM runtime — call it in parallel with the wasm download so the editor's
* first enumerate reads a warm cache instead of freezing on N cold bundle
* fetches. Best-effort: a lib that fails to presync is skipped (it still loads
* lazily later); the SyncStack dedups, so a lib the wasm reaches mid-presync
* just awaits the same in-flight fetch. `onProgress` reports per-lib so the load
* screen can name what's syncing. Optional: sources without a client-side cache
* (per-item remote) omit it.
*/
presync?(opts?: {
/** Limit to libs holding this item kind ("symbol" | "footprint"). */
kind?: string;
/** Max concurrent bundle fetches (default 8). */
concurrency?: number;
onProgress?: (p: LibPresyncProgress) => void;
signal?: AbortSignal;
}): Promise<void>;
/**
* Persist one item body into a writable (user) lib. Optional: read-only
* sources omit it (a save into a non-writable source resolves false).
* `body` is a complete fork-native `kicad_symbol_lib` s-expr.
*/
saveItemBody?(
libId: string,
kind: string,
name: string,
body: string,
): Promise<boolean>;
/**
* Create a user library (returns its `LibInfo`, or null if unsupported / on
* conflict). Used by boot to ensure the owner has a writable target.
*/
createLib?(name: string): Promise<LibInfo | null>;
/**
* The publish-time footprint index as raw JSON text:
* { schema, tag, libs: { "<libId>": [["<name>", <uniquePadCount>], …] } }
* One small artifact covering EVERY footprint lib, so the editor's symbol-
* chooser footprint selector can pin-count/wildcard-filter the full set
* without fat-loading a single body (kicad `filterFootprints`, pcbnew.cpp).
* Optional: sources without a published index omit it (or resolve null) and
* the C++ side falls back to the per-lib lazy load.
*/
getFpIndex?(): Promise<string | null>;
}
/**
* The function the WASM lib plugins call via the JS bridge. Both the symbol
* plugin (`SCH_IO_PCBJAM_LIB`) and the footprint plugin (`PCB_IO_PCBJAM_FP`)
* call the same hook; `kind` (4th arg) discriminates the item kind. The symbol
* plugin omits it (passes 3 args) so it defaults to "symbol" — keeping the
* existing eeschema binary correct with no rebuild.
*/
export type KicadLibsRequest = (
op: string,
lib: string,
arg: string,
kind?: string,
// "bodies" returns a framed Uint8Array (raw item bytes, copied as-is across the
// bridge); every other op returns a string (or null).
) => Promise<string | Uint8Array | null>;
declare global {
interface Window {
kicadLibs?: { request: KicadLibsRequest };
}
}
/**
* Events the libs bridge dispatches on `window` so the editor chrome (WasmTool)
* can show a loading state for the otherwise-invisible item fetch, and surface an
* error when a body can't be loaded (e.g. a backend 404). Decoupled via events so
* `wasm/libs` stays UI-agnostic.
*/
export const LIB_BUSY_EVENT = "pcbjam:lib-busy";
export const LIB_ERROR_EVENT = "pcbjam:lib-error";
/**
* Fired around the bulk "fat list" crossing (`list`/`bodies`) — the eager
* idb→wasm library load that can take tens of seconds on the full CDN set. Unlike
* LIB_BUSY (per-item open/save), this brackets the whole-library hydrate so the
* editor chrome can show a "loading libraries, just slow" overlay instead of a
* silent freeze. One `loading:true` per lib as its crossing starts, `loading:false`
* as the bytes are handed to the bridge; the consumer coalesces the per-lib run.
*/
export const LIB_LOADING_EVENT = "pcbjam:lib-loading";
export interface LibBusyDetail {
busy: boolean;
op: string;
kind: string;
name: string;
}
export interface LibErrorDetail {
message: string;
}
export interface LibLoadingDetail {
loading: boolean;
kind: string;
/** Libraries whose fat-load has started this burst (1-based, increasing). */
done: number;
/** Total libs of this kind to load (from listLibs), or 0 if unknown. */
total: number;
}
function emitLibBusy(detail: LibBusyDetail): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(LIB_BUSY_EVENT, { detail }));
}
function emitLibLoading(detail: LibLoadingDetail): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(LIB_LOADING_EVENT, { detail }));
}
function emitLibError(message: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(LIB_ERROR_EVENT, { detail: { message } }),
);
}
/** Optional artificial latency (`?libdelay=1500`) to exercise the bridge. */
function artificialDelayMs(): number {
const raw = new URLSearchParams(window.location.search).get("libdelay");
const n = raw ? Number(raw) : 0;
return Number.isFinite(n) && n > 0 ? n : 0;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Slow-path "fat list" for sources without a bulk `getAllItems` (the example
* backend / per-item remote): just listItems + one getItemBody each. Same N
* round-trips as before — but it keeps the single WASM-side code path (the plugin
* always asks for bodies once) working against every source. The per-item gets
* run 8 at a time (they're independent HTTP round-trips); order is preserved.
*/
async function fallbackGetAllItems(
source: LibsSource,
libId: string,
): Promise<Array<{ kind: string; name: string; body: Uint8Array }>> {
const items = await source.listItems(libId);
const enc = new TextEncoder();
const bodies = await asyncMap(
items,
(it) => source.getItemBody(libId, it.kind, it.name).catch(() => null),
8,
);
return items.flatMap((it, i) => {
const body = bodies[i];
return body != null
? [{ kind: it.kind, name: it.name, body: enc.encode(body) }]
: [];
});
}
/** Escape a string for a KiCad s-expr quoted token. */
function sexprEscape(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/** Build sym-lib-table content (KiCad v7) with one PCBJAM row per lib. */
export function buildSymLibTable(libsList: LibInfo[]): string {
const rows = libsList.map((l) => {
const descr = l.description ? sexprEscape(l.description) : "";
return ` (lib (name "${sexprEscape(l.name)}")(type "PCBJAM")(uri "${libUri(
l.id,
)}")(options "")(descr "${descr}"))`;
});
return `(sym_lib_table\n (version 7)\n${rows.join("\n")}${
rows.length ? "\n" : ""
})\n`;
}
/**
* Build fp-lib-table content (KiCad v7) with one PCBJAM_FP row per lib. The
* footprint editor selects the plugin from this row's `type` field (via
* PCB_IO_MGR::EnumFromStr), so it MUST be "PCBJAM_FP" to match the registered
* plugin name. Same /mnt/pcbjam/<id> URI as symbols (the same lib id can appear
* in both tables — user libs are kind-agnostic containers).
*/
export function buildFpLibTable(libsList: LibInfo[]): string {
const rows = libsList.map((l) => {
const descr = l.description ? sexprEscape(l.description) : "";
return ` (lib (name "${sexprEscape(
l.name,
)}")(type "PCBJAM_FP")(uri "${libUri(
l.id,
)}")(options "")(descr "${descr}"))`;
});
return `(fp_lib_table\n (version 7)\n${rows.join("\n")}${
rows.length ? "\n" : ""
})\n`;
}
/**
* Install `window.kicadLibs` backed by a `LibsSource`. Both lib plugins call
* `request(op, "/mnt/pcbjam/<id>", arg, kind)` (kind defaults to "symbol" so the
* symbol plugin's 3-arg calls still work):
* "list" -> JSON {"symbols":[...]} | {"footprints":[...]} (names of that kind)
* "get" -> the item body s-expr (arg = item name; null if absent)
* "save" -> "ok" / null (arg = JSON {"name":..,"body":..})
* "index" -> the publish-time footprint index JSON (source-global; lib arg
* ignored) / null when the source has none — see getFpIndex.
*/
export function installLibsProvider(
source: LibsSource,
log: (msg: string) => void,
): void {
if (window.kicadLibs) return;
const delay = artificialDelayMs();
// Per-burst fat-load progress. The plugin fat-loads every library of a kind
// one bridge crossing at a time, so counting `bodies` requests gives real
// per-lib progress; `total` comes from listLibs(kind) (cached). A trailing
// timer resets the counter once a burst goes quiet, so a later open starts
// fresh (mirrors the overlay's own hide debounce).
let fatDone = 0;
let fatTotal = 0;
let fatResetTimer: ReturnType<typeof setTimeout> | undefined;
const request: KicadLibsRequest = async (op, lib, arg, kind = "symbol") => {
// 3D models are addressed by ref, not lib-table URI (the C++ ensure bridge
// passes an empty lib) — dispatch before the lib-id parse would null it out.
if (kind === "model3d") {
log(`[libs] request op=${op} kind=model3d arg=${arg}`);
return handleModel3dRequest(op, arg);
}
// The footprint index is source-global (not per-lib) — dispatch before the
// lib-id parse, which would reject the bare mount URI the C++ side passes.
if (op === "index") {
log(`[libs] request op=index kind=${kind}`);
return kind === "footprint" && source.getFpIndex
? await source.getFpIndex()
: null;
}
const id = libIdFromUri(lib);
log(`[libs] request op=${op} kind=${kind} lib=${lib} (id=${id}) arg=${arg}`);
if (!id) return null;
if (delay) await sleep(delay);
// "get"/"save" are user-triggered (open/save an item) and otherwise give no
// visible feedback — broadcast busy + errors so the editor can show them.
const userFacing = op === "get" || op === "save";
if (userFacing) emitLibBusy({ busy: true, op, kind, name: arg });
try {
switch (op) {
case "list": {
// Each plugin parses its own key: footprints / symbols.
const key = kind === "footprint" ? "footprints" : "symbols";
// "bodies" (arg) = the fat list: every item's body in one crossing, so
// the plugin pre-fills its cache and never per-item `get`s. Falls back
// to listItems + N getItemBody for sources without bulk read.
if (arg === "bodies") {
// Bracket the whole-library hydrate so the editor can overlay a
// "loading libraries (slow, not hung)" state over the otherwise
// silent multi-second freeze. `true` before the (async) IDB read so
// the overlay can paint while the C++ side is Asyncify-suspended;
// `false` once the bytes are framed and about to cross the bridge.
clearTimeout(fatResetTimer);
if (fatTotal === 0) {
// First lib of the burst — learn the total for the progress bar.
try {
fatTotal = (await source.listLibs(kind)).length;
} catch {
fatTotal = 0;
}
}
fatDone++;
emitLibLoading({ loading: true, kind, done: fatDone, total: fatTotal });
try {
const all = source.getAllItems
? await source.getAllItems(id)
: await fallbackGetAllItems(source, id);
const items = all.filter((i) => i.kind === kind);
// "Copy as-is" framing: a one-line JSON header (names + UTF-8 byte
// lengths), a newline, then every body's RAW bytes concatenated — no
// JSON escaping. The C++ bridge memcpy's this straight into the wasm
// heap; the plugin parses the small header and slices the bodies, so
// none of the (hundreds of MB of) s-expr gets un-escaped.
const header = JSON.stringify({
[key]: items.map((i) => ({ name: i.name, len: i.body.length })),
});
const headerBytes = new TextEncoder().encode(header + "\n");
const total =
headerBytes.length +
items.reduce((n, i) => n + i.body.length, 0);
const out = new Uint8Array(total);
out.set(headerBytes, 0);
let off = headerBytes.length;
for (const i of items) {
out.set(i.body, off);
off += i.body.length;
}
return out;
} finally {
emitLibLoading({
loading: false,
kind,
done: fatDone,
total: fatTotal,
});
// Reset the per-burst counter once the run goes quiet, so the next
// open starts from zero (the WASM drives these back-to-back).
fatResetTimer = setTimeout(() => {
fatDone = 0;
fatTotal = 0;
}, 1500);
}
}
const items = await source.listItems(id);
const names = items
.filter((i) => i.kind === kind)
.map((i) => i.name);
return JSON.stringify({ [key]: names });
}
case "get": {
const body = await source.getItemBody(id, kind, arg);
if (body === null) {
emitLibError(
`Couldn't open "${arg}" — the backend has no body for it (404).`,
);
}
return body;
}
case "save": {
let parsed: { name?: string; body?: string };
try {
parsed = JSON.parse(arg) as { name?: string; body?: string };
} catch {
log(`[libs] save: bad JSON arg`);
return null;
}
if (!parsed.name || !parsed.body) return null;
if (!source.saveItemBody) {
log(`[libs] save: source has no write support (lib=${id})`);
return null;
}
const ok = await source.saveItemBody(
id,
kind,
parsed.name,
parsed.body,
);
if (!ok) emitLibError(`Couldn't save "${parsed.name}".`);
return ok ? "ok" : null;
}
default:
return null;
}
} catch (e) {
log(`[libs] request failed: ${String(e)}`);
if (userFacing) emitLibError(`Failed to ${op} "${arg}".`);
return null;
} finally {
if (userFacing) emitLibBusy({ busy: false, op, kind, name: arg });
}
};
window.kicadLibs = { request };
log("[libs] provider installed");
}