feat: libs 0003 — remote provider + example backend serving; bump kicad, shared

standalone: LibsSource abstraction (remote over the contract + static fallback),
generic window.kicadLibs provider, sym-lib-table generated from the backend's
lib list at boot (replaces the spike). backend: serve pre-built self-contained
symbol bodies from LIBS_DIR (listLibs/listLibItems + raw item-body route).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-12 19:15:37 +02:00
commit d2d2c19a8c
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
12 changed files with 447 additions and 145 deletions

View file

@ -12,7 +12,12 @@ import {
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react";
import { WASM_ASSET_BASE_URL, yjsProviderConfig, type DocSource } from "@/lib/config";
import {
libsSourceConfig,
WASM_ASSET_BASE_URL,
yjsProviderConfig,
type DocSource,
} from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
@ -437,6 +442,7 @@ export function WasmTool({
log: append,
onStatus: setStatus,
onAbort: oom.onAbort,
libsSource: libsSourceConfig(),
});
// Register the save sink before the file opens: from here on, every
// editor File→Save (MEMFS write) is routed onward through saveBytes.

View file

@ -9,6 +9,9 @@ export const WASM_ASSET_BASE_URL =
import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm";
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { remoteLibsSource } from "@/wasm/libs/remote-source";
import type { LibsSource } from "@/wasm/libs/source";
import { staticLibsSource } from "@/wasm/libs/static-source";
/**
* Which Yjs collab provider this deployment uses (one active per env), and its
@ -43,3 +46,18 @@ export type DocSource = "api" | "ydoc";
export function docSourceConfig(): DocSource {
return import.meta.env.VITE_DOC_SOURCE === "ydoc" ? "ydoc" : "api";
}
/**
* Which library source backs the editor's symbol chooser (env `VITE_LIBS_SOURCE`):
* "remote" (default) — fetch from the backend at `API_BASE_URL` over the
* shared contract (origins served by the registry, or the
* GPL example backend).
* "static" — built-in offline example symbols (no backend).
* "off" — disable libs (empty sym-lib-table).
*/
export function libsSourceConfig(): LibsSource | null {
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
if (kind === "off") return null;
if (kind === "static") return staticLibsSource();
return remoteLibsSource(API_BASE_URL);
}

View file

@ -5,11 +5,8 @@ import {
TOOL_ARGV0,
TOOL_NEEDS_CONFIG_SEED,
} from "./constants";
import {
installSpikeLibsProvider,
PCBJAM_LIB_MOUNT,
SPIKE_LIB_TABLE_ROW,
} from "./libs/spike-provider";
import { buildSymLibTable, installLibsProvider, type LibsSource } from "./libs/source";
import { PCBJAM_LIB_MOUNT } from "./libs/uri";
/**
* Boot a KiCad tool directly in the main React document — no iframe.
@ -45,6 +42,9 @@ export interface BootOptions {
/** OOM recovery hook (feature 0002): emscripten `abort()` routes here so a
* soft OOM can respawn a fresh tab. Optional — boot works without it. */
onAbort?: (what: string) => void;
/** Library source backing `window.kicadLibs`. Null/omitted disables libs
* (an empty sym-lib-table is seeded). Its libs become sym-lib-table rows. */
libsSource?: LibsSource | null;
}
let booted: { tool: Tool; promise: Promise<void> } | null = null;
@ -83,7 +83,7 @@ function loadScript(src: string): Promise<void> {
}
async function doBoot(opts: BootOptions): Promise<void> {
const { tool, base, container, log, onStatus, onAbort } = opts;
const { tool, base, container, log, onStatus, onAbort, libsSource } = opts;
const w = window as ToolWindow;
// The wasm reads the top-level frame geometry from a GLOBAL `mainWindow`
@ -93,8 +93,21 @@ async function doBoot(opts: BootOptions): Promise<void> {
// mismatches the viewport, breaking the whole AUI layout (toolbars/panels).
(w as unknown as { mainWindow: HTMLElement }).mainWindow = container;
// libs 0002 spike: must exist before any plugin call can suspend on it.
installSpikeLibsProvider(log);
// libs: install the provider (must exist before any plugin call can suspend
// on it) and generate the sym-lib-table from the source's libs — both before
// the wasm boots (the table is seeded in preRun below). No source → empty
// table, libs disabled.
let symLibTable = "(sym_lib_table\n (version 7)\n)\n";
if (libsSource) {
installLibsProvider(libsSource, log);
try {
const libsList = await libsSource.listLibs();
symLibTable = buildSymLibTable(libsList);
log(`[libs] seeded ${libsList.length} lib(s) into sym-lib-table`);
} catch (e) {
log(`[libs] listLibs failed, seeding empty table: ${String(e)}`);
}
}
onStatus("Downloading…");
@ -186,12 +199,9 @@ async function doBoot(opts: BootOptions): Promise<void> {
2,
),
);
// libs 0002 spike: one remote lib row; the PCBJAM plugin resolves it
// through window.kicadLibs (installed in doBoot).
writeIfAbsent(
`${KICAD_CONFIG_DIR}/sym-lib-table`,
`(sym_lib_table\n (version 7)\n${SPIKE_LIB_TABLE_ROW}\n)\n`,
);
// libs: rows generated in doBoot from the lib source; the PCBJAM plugin
// resolves each via window.kicadLibs.
writeIfAbsent(`${KICAD_CONFIG_DIR}/sym-lib-table`, symLibTable);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/fp-lib-table`,
"(fp_lib_table\n (version 7)\n)\n",

View file

@ -0,0 +1,44 @@
import { contract } from "@pcbjam/shared";
import { initClient } from "@ts-rest/core";
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
/**
* A `LibsSource` backed by a contract-conforming backend (the closed registry
* server, or the GPL example backend). List ops go through the ts-rest client;
* item bodies stream from the raw text route `GET /api/libs/:lib/items/:kind/:name`
* (binary/text does not round-trip ts-rest — same as file-byte download).
*/
export function remoteLibsSource(apiBase: string): LibsSource {
const client = initClient(contract, { baseUrl: apiBase, baseHeaders: {} });
return {
async listLibs(): Promise<LibInfo[]> {
const res = await client.listLibs();
if (res.status !== 200) return [];
return res.body.map((l) => ({
id: l.id,
name: l.name,
description: l.description ?? null,
}));
},
async listItems(libId: string): Promise<LibItemInfo[]> {
const res = await client.listLibItems({ params: { lib: libId } });
if (res.status !== 200) return [];
return res.body.map((i) => ({ kind: i.kind, name: i.name }));
},
async getItemBody(
libId: string,
kind: string,
name: string,
): Promise<string | null> {
const url =
`${apiBase}/api/libs/${encodeURIComponent(libId)}/items/` +
`${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
const res = await fetch(url);
if (!res.ok) return null;
return await res.text();
},
};
}

View file

@ -0,0 +1,114 @@
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;
}
export interface LibItemInfo {
kind: string; // 'symbol' | 'footprint' | 'model3d'
name: string;
}
export interface LibsSource {
/** Libraries to expose to the editor (one sym-lib-table row each). */
listLibs(): 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>;
}
/** The function the WASM `SCH_IO_PCBJAM_LIB` plugin calls via the JS bridge. */
export type KicadLibsRequest = (
op: string,
lib: string,
arg: string,
) => Promise<string | null>;
declare global {
interface Window {
kicadLibs?: { request: KicadLibsRequest };
}
}
/** 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));
/** 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`;
}
/**
* Install `window.kicadLibs` backed by a `LibsSource`. The plugin calls
* `request(op, "/mnt/pcbjam/<id>", arg)`:
* "list" -> JSON {"symbols":[...]} (symbol names in the lib)
* "get" -> the item body s-expr (arg = symbol name)
*/
export function installLibsProvider(
source: LibsSource,
log: (msg: string) => void,
): void {
if (window.kicadLibs) return;
const delay = artificialDelayMs();
const request: KicadLibsRequest = async (op, lib, arg) => {
const id = libIdFromUri(lib);
log(`[libs] request op=${op} lib=${lib} (id=${id}) arg=${arg}`);
if (!id) return null;
if (delay) await sleep(delay);
try {
switch (op) {
case "list": {
const items = await source.listItems(id);
const symbols = items
.filter((i) => i.kind === "symbol")
.map((i) => i.name);
return JSON.stringify({ symbols });
}
case "get":
return await source.getItemBody(id, "symbol", arg);
default:
return null;
}
} catch (e) {
log(`[libs] request failed: ${String(e)}`);
return null;
}
};
window.kicadLibs = { request };
log("[libs] provider installed");
}

View file

@ -1,128 +0,0 @@
/**
* libs 0002 spike: a static JS-side library provider.
*
* The kicad fork's SCH_IO_PCBJAM_LIB plugin (lib-table type "PCBJAM") calls
* `globalThis.kicadLibs.request(op, lib, arg)` through an Asyncify suspension
* (EM_ASYNC_JS) and blocks the C++ stack until the returned promise resolves:
*
* request("list", "pcbjam://spike", "") -> JSON {"symbols": [...]}
* request("get", "pcbjam://spike", "R") -> full kicad_symbol_lib s-expr
* document holding that symbol
*
* This stub serves two handcrafted symbols from memory — no server. An
* artificial delay (`?libdelay=1500` in the URL) exercises the reentrancy
* question: the tab must stay alive while C++ is suspended mid-fetch.
*/
export const SPIKE_LIB_NICKNAME = "pcbjam-spike";
/**
* pcbjam lib URIs are absolute POSIX paths under this prefix. Absolute so that
* KiCad's lib-table URI expansion (ExpandURI -> wxFileName::MakeAbsolute) is a
* no-op and the path reaches the plugin/provider unmangled — a "scheme://" URI
* gets rewritten to "/scheme:/..." against the cwd, which differs per project.
*/
export const PCBJAM_LIB_MOUNT = "/mnt/pcbjam";
export const PCBJAM_LIB_PREFIX = `${PCBJAM_LIB_MOUNT}/`;
export const SPIKE_LIB_URI = `${PCBJAM_LIB_PREFIX}spike`;
/** sym-lib-table row seeded at boot (see boot.ts seedKicadConfig). */
export const SPIKE_LIB_TABLE_ROW = ` (lib (name "${SPIKE_LIB_NICKNAME}")(type "PCBJAM")(uri "${SPIKE_LIB_URI}")(options "")(descr "pcbjam libs 0002 spike"))`;
const wrapLib = (symbolBody: string) =>
`(kicad_symbol_lib (version 20241209) (generator "pcbjam") (generator_version "0.1")
${symbolBody}
)
`;
const SYMBOLS: Record<string, string> = {
R: ` (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
(property "Footprint" "" (at -1.778 0 90) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Resistor (pcbjam spike)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "ki_keywords" "R res resistor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
)
)`,
C: ` (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Footprint" "" (at 0.9652 -3.81 0) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Capacitor (pcbjam spike)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "ki_keywords" "cap capacitor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "C_0_1"
(polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
(polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
)
(symbol "C_1_1"
(pin passive line (at 0 3.81 270) (length 2.794)
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 2.794)
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
)
)`,
};
export type KicadLibsRequest = (
op: string,
lib: string,
arg: string,
) => Promise<string | null>;
declare global {
interface Window {
kicadLibs?: { request: KicadLibsRequest };
}
}
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));
/** Recover the lib id from a "/mnt/pcbjam/<id>" URI (arrives unmangled). */
function libIdFromUri(lib: string): string | null {
return lib.startsWith(PCBJAM_LIB_PREFIX)
? lib.slice(PCBJAM_LIB_PREFIX.length)
: null;
}
export function installSpikeLibsProvider(log: (msg: string) => void): void {
if (window.kicadLibs) return;
const delay = artificialDelayMs();
const request: KicadLibsRequest = async (op, lib, arg) => {
log(`[libs] request op=${op} lib=${lib} arg=${arg} (delay=${delay}ms)`);
if (libIdFromUri(lib) !== "spike") return null;
if (delay) await sleep(delay);
switch (op) {
case "list":
return JSON.stringify({ symbols: Object.keys(SYMBOLS) });
case "get": {
const body = SYMBOLS[arg];
return body ? wrapLib(body) : null;
}
default:
return null;
}
};
window.kicadLibs = { request };
log(`[libs] spike provider installed (${SPIKE_LIB_URI})`);
}

View file

@ -0,0 +1,78 @@
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
/**
* A static, in-memory `LibsSource` — two handcrafted symbols, no backend. The
* offline fallback when no API base is configured (and the origin of the libs
* 0002 spike). Keeps the editor's lib chooser populated in a bare checkout.
*/
const STATIC_LIB: LibInfo = {
id: "examples",
name: "pcbjam-examples",
description: "Built-in example symbols (offline)",
};
const wrapLib = (symbolBody: string) =>
`(kicad_symbol_lib (version 20241209) (generator "pcbjam") (generator_version "0.1")
${symbolBody}
)
`;
const SYMBOLS: Record<string, string> = {
R: ` (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
(property "Footprint" "" (at -1.778 0 90) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Resistor (pcbjam example)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "ki_keywords" "R res resistor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
)
)`,
C: ` (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Footprint" "" (at 0.9652 -3.81 0) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Capacitor (pcbjam example)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "ki_keywords" "cap capacitor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "C_0_1"
(polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
(polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
)
(symbol "C_1_1"
(pin passive line (at 0 3.81 270) (length 2.794)
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 2.794)
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
)
)`,
};
export function staticLibsSource(): LibsSource {
return {
async listLibs(): Promise<LibInfo[]> {
return [STATIC_LIB];
},
async listItems(libId: string): Promise<LibItemInfo[]> {
if (libId !== STATIC_LIB.id) return [];
return Object.keys(SYMBOLS).map((name) => ({ kind: "symbol", name }));
},
async getItemBody(
libId: string,
_kind: string,
name: string,
): Promise<string | null> {
if (libId !== STATIC_LIB.id) return null;
const body = SYMBOLS[name];
return body ? wrapLib(body) : null;
},
};
}

View file

@ -0,0 +1,20 @@
/**
* pcbjam lib URIs are absolute POSIX paths under this mount. Absolute so that
* KiCad's lib-table URI expansion (ExpandURI -> wxFileName::MakeAbsolute) is a
* no-op and the path reaches the plugin/provider unmangled — a "scheme://" URI
* gets rewritten to "/scheme:/..." against the cwd, which differs per project.
*/
export const PCBJAM_LIB_MOUNT = "/mnt/pcbjam";
export const PCBJAM_LIB_PREFIX = `${PCBJAM_LIB_MOUNT}/`;
/** The lib-table URI for a lib id. */
export function libUri(id: string): string {
return `${PCBJAM_LIB_PREFIX}${id}`;
}
/** Recover the lib id from a "/mnt/pcbjam/<id>" URI (arrives unmangled). */
export function libIdFromUri(uri: string): string | null {
return uri.startsWith(PCBJAM_LIB_PREFIX)
? uri.slice(PCBJAM_LIB_PREFIX.length)
: null;
}