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:
parent
29a7dcc233
commit
d2d2c19a8c
12 changed files with 447 additions and 145 deletions
107
web/backend/src/libs.ts
Normal file
107
web/backend/src/libs.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// Minimal reference libraries serving for the @pcbjam/shared libs contract.
|
||||
//
|
||||
// Serves PRE-BUILT self-contained symbol bodies from a directory (LIBS_DIR),
|
||||
// laid out as:
|
||||
//
|
||||
// <LIBS_DIR>/<LibName>/index.json { items: [{kind,name,description,keywords}], ... }
|
||||
// <LIBS_DIR>/<LibName>/<Symbol>.kicad_sym a complete kicad_symbol_lib s-expr
|
||||
// <LIBS_DIR>/<LibName>/LICENSE.md attribution (CC-BY-SA travels with data)
|
||||
//
|
||||
// This backend does NO parsing — bodies are produced upstream (the closed
|
||||
// ingestion/extractor) so the open reference server stays trivial. If LIBS_DIR
|
||||
// is unset or empty, the lib endpoints simply report no libraries.
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { Lib, LibItem } from "@pcbjam/shared";
|
||||
|
||||
export interface LibsConfig {
|
||||
dir: string | null;
|
||||
}
|
||||
|
||||
export function libsConfig(): LibsConfig {
|
||||
const dir = process.env.LIBS_DIR;
|
||||
return { dir: dir ? path.resolve(process.cwd(), dir) : null };
|
||||
}
|
||||
|
||||
/** A lib id is its directory name; reject anything that isn't a plain segment. */
|
||||
function safeLibDir(root: string, lib: string): string | null {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(lib)) return null;
|
||||
return path.join(root, lib);
|
||||
}
|
||||
|
||||
interface IndexFile {
|
||||
items?: { kind: string; name: string; description?: string | null; keywords?: string | null }[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
async function readIndex(dir: string): Promise<IndexFile | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(dir, "index.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLibs(cfg: LibsConfig): Promise<Lib[]> {
|
||||
if (!cfg.dir) return [];
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(cfg.dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const libs: Lib[] = [];
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
||||
const idx = await readIndex(path.join(cfg.dir, e.name));
|
||||
libs.push({
|
||||
id: e.name,
|
||||
name: e.name,
|
||||
type: "origin",
|
||||
description: idx?.description ?? null,
|
||||
itemCount: idx?.items?.length ?? undefined,
|
||||
});
|
||||
}
|
||||
libs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return libs;
|
||||
}
|
||||
|
||||
export async function listLibItems(
|
||||
cfg: LibsConfig,
|
||||
lib: string,
|
||||
): Promise<LibItem[] | null> {
|
||||
if (!cfg.dir) return null;
|
||||
const dir = safeLibDir(cfg.dir, lib);
|
||||
if (!dir) return null;
|
||||
const idx = await readIndex(dir);
|
||||
if (!idx) return null;
|
||||
return (idx.items ?? []).map((i) => ({
|
||||
kind: i.kind,
|
||||
name: i.name,
|
||||
description: i.description ?? null,
|
||||
keywords: i.keywords ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Resolve the on-disk body file for one item, or null (guarded). */
|
||||
export function itemBodyPath(
|
||||
cfg: LibsConfig,
|
||||
lib: string,
|
||||
kind: string,
|
||||
name: string,
|
||||
): string | null {
|
||||
if (!cfg.dir || kind !== "symbol") return null;
|
||||
const dir = safeLibDir(cfg.dir, lib);
|
||||
if (!dir) return null;
|
||||
// Symbol names allow a wide charset but never path separators.
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
|
||||
return null;
|
||||
}
|
||||
return path.join(dir, `${name}.kicad_sym`);
|
||||
}
|
||||
|
||||
export function streamBody(absPath: string) {
|
||||
return createReadStream(absPath);
|
||||
}
|
||||
|
|
@ -19,6 +19,13 @@ import {
|
|||
type Project,
|
||||
type ProjectFile,
|
||||
} from "@pcbjam/shared";
|
||||
import {
|
||||
itemBodyPath,
|
||||
type LibsConfig,
|
||||
libsConfig,
|
||||
listLibItems,
|
||||
listLibs,
|
||||
} from "./libs.js";
|
||||
|
||||
const PROJECT_DIR = path.resolve(
|
||||
process.cwd(),
|
||||
|
|
@ -121,6 +128,8 @@ async function main(): Promise<void> {
|
|||
});
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
const libs: LibsConfig = libsConfig();
|
||||
|
||||
const s = initServer();
|
||||
const router = s.router(contract, {
|
||||
listProjects: async () => ({ status: 200, body: [await project()] }),
|
||||
|
|
@ -139,9 +148,33 @@ async function main(): Promise<void> {
|
|||
}
|
||||
return { status: 200 as const, body: await walk(PROJECT_DIR) };
|
||||
},
|
||||
listLibs: async () => ({ status: 200 as const, body: await listLibs(libs) }),
|
||||
listLibItems: async ({ params }) => {
|
||||
const items = await listLibItems(libs, params.lib);
|
||||
if (items === null) {
|
||||
return { status: 404 as const, body: { message: "library not found" } };
|
||||
}
|
||||
return { status: 200 as const, body: items };
|
||||
},
|
||||
});
|
||||
await app.register(s.plugin(router));
|
||||
|
||||
// Streamed item-body fetch (text; intentionally not a ts-rest endpoint).
|
||||
app.get<{ Params: { lib: string; kind: string; name: string } }>(
|
||||
"/api/libs/:lib/items/:kind/:name",
|
||||
async (req, reply) => {
|
||||
const abs = itemBodyPath(libs, req.params.lib, req.params.kind, req.params.name);
|
||||
if (!abs) return reply.code(400).send({ message: "invalid item" });
|
||||
const st = await fs.stat(abs).catch(() => null);
|
||||
if (!st?.isFile()) {
|
||||
return reply.code(404).send({ message: "item not found" });
|
||||
}
|
||||
reply.header("Content-Type", "text/plain; charset=utf-8");
|
||||
reply.header("Content-Length", st.size);
|
||||
return reply.send(createReadStream(abs));
|
||||
},
|
||||
);
|
||||
|
||||
// Streamed file-byte download (binary; intentionally not a ts-rest endpoint).
|
||||
app.get<{ Params: { project: string; "*": string } }>(
|
||||
"/api/projects/:project/files/*",
|
||||
|
|
|
|||
Loading…
Reference in a new issue