app
JLCPCB Parts Library
Public Made by Adomby adom
Check the JLCPCB / LCSC parts library before you fab — Basic vs Extended tier, LCSC stock and assembly pricing, so you avoid surprise feeder-setup fees. CLI, a Hydrogen app, and a shared backend.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
/**
* JLCPCB Component Search Server — Shared Service Edition
*
* HTTP API backed by a local SQLite database (from CDFER/jlcpcb-parts-database).
* No upstream dependency at query time — fully self-contained.
*
* This runs as a shared service so individual user containers don't need
* the 1.3GB SQLite database.
*
* Port: 8774 (configurable via JLCPCB_PORT)
* DB: /data/jlcpcb-components.sqlite3 (configurable via JLCPCB_DB_PATH)
*
* Supports SIGHUP for graceful DB reload (used by the daily cron job).
*/
import { createServer } from 'http';
import { existsSync, statSync } from 'fs';
// Also expose as `fs.statSync` so the telemetry block reads naturally.
const fs = { statSync };
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import Database from 'better-sqlite3';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.JLCPCB_PORT || '8774', 10);
const DB_PATH = process.env.JLCPCB_DB_PATH || join(__dirname, 'jlcpcb-components.sqlite3');
// ── Database management ─────────────────────────────────────────
let db, ftsTable, allCategories, CATEGORY_MAP;
function openDatabase() {
if (!existsSync(DB_PATH)) {
console.error(`[jlcpcb] Database not found at ${DB_PATH}`);
console.error(`[jlcpcb] Run: node setup-db.js`);
process.exit(1);
}
db = new Database(DB_PATH, { readonly: true });
db.pragma('journal_mode = WAL');
db.pragma('cache_size = -64000');
const hasSearchFTS = Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='search_fts'").get());
const hasComponentsFTS = Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='components_fts'").get());
ftsTable = hasSearchFTS ? 'search_fts' : hasComponentsFTS ? 'components_fts' : null;
if (!ftsTable) {
console.warn('[jlcpcb] WARNING: No FTS table found. Full-text search will use LIKE (slower).');
}
allCategories = db.prepare('SELECT id, category, subcategory FROM categories').all();
CATEGORY_MAP = buildCategoryMap();
console.log(`[jlcpcb] Database loaded: ${DB_PATH}`);
console.log(`[jlcpcb] FTS: ${ftsTable || 'none (LIKE fallback)'}`);
}
function buildCategoryMap() {
function findCategoryIds(opts) {
return allCategories
.filter(c => {
if (opts.subcategoryLike) return c.subcategory.toLowerCase().includes(opts.subcategoryLike.toLowerCase());
if (opts.categoryLike) return c.category.toLowerCase().includes(opts.categoryLike.toLowerCase());
return false;
})
.map(c => c.id);
}
const map = {
resistors: findCategoryIds({ categoryLike: 'resistor' }),
capacitors: findCategoryIds({ categoryLike: 'capacitor' }),
microcontrollers: findCategoryIds({ subcategoryLike: 'microcontroller' }),
ldos: findCategoryIds({ subcategoryLike: 'LDO' }),
voltage_regulators: findCategoryIds({ subcategoryLike: 'voltage regulator' }),
boost_converters: findCategoryIds({ subcategoryLike: 'DC-DC' }),
buck_boost_converters: findCategoryIds({ subcategoryLike: 'DC-DC' }),
mosfets: findCategoryIds({ subcategoryLike: 'MOSFET' }),
bjt_transistors: findCategoryIds({ subcategoryLike: 'bipolar transistor' }),
diodes: findCategoryIds({ categoryLike: 'diode' }),
leds: findCategoryIds({ subcategoryLike: 'LED' }),
led_drivers: findCategoryIds({ subcategoryLike: 'LED driver' }),
switches: findCategoryIds({ subcategoryLike: 'switch' }),
fuses: findCategoryIds({ subcategoryLike: 'fuse' }),
relays: findCategoryIds({ subcategoryLike: 'relay' }),
headers: findCategoryIds({ subcategoryLike: 'pin header' }),
usb_c_connectors: findCategoryIds({ subcategoryLike: 'USB connector' }),
jst_connectors: findCategoryIds({ subcategoryLike: 'wire to board' }),
wire_to_board_connectors: findCategoryIds({ subcategoryLike: 'wire to board' }),
io_expanders: findCategoryIds({ subcategoryLike: 'I/O expander' }),
fpgas: findCategoryIds({ subcategoryLike: 'FPGA' }),
arm_processors: findCategoryIds({ subcategoryLike: 'microcontroller' }),
risc_v_processors: findCategoryIds({ subcategoryLike: 'microcontroller' }),
wifi_modules: findCategoryIds({ subcategoryLike: 'WiFi' }),
accelerometers: findCategoryIds({ subcategoryLike: 'accelerometer' }),
gyroscopes: findCategoryIds({ subcategoryLike: 'gyroscope' }),
gas_sensors: findCategoryIds({ subcategoryLike: 'gas sensor' }),
adcs: findCategoryIds({ subcategoryLike: 'analog to digital' }),
dacs: findCategoryIds({ subcategoryLike: 'digital to analog' }),
analog_multiplexers: findCategoryIds({ subcategoryLike: 'analog switch' }),
analog_switches: findCategoryIds({ subcategoryLike: 'analog switch' }),
potentiometers: findCategoryIds({ subcategoryLike: 'potentiometer' }),
resistor_arrays: findCategoryIds({ subcategoryLike: 'resistor network' }),
battery_holders: findCategoryIds({ subcategoryLike: 'battery' }),
lcd_display: findCategoryIds({ subcategoryLike: 'LCD' }),
oled_display: findCategoryIds({ subcategoryLike: 'OLED' }),
};
for (const [name, ids] of Object.entries(map)) {
if (ids.length === 0) console.warn(`[jlcpcb] CATEGORY_MAP: no match for "${name}"`);
}
return map;
}
// Open DB on startup
openDatabase();
// SIGHUP handler: gracefully reload the database (used by cron after DB swap)
process.on('SIGHUP', () => {
console.log('[jlcpcb] SIGHUP received — reloading database...');
try {
db.close();
openDatabase();
console.log('[jlcpcb] Database reloaded successfully.');
} catch (err) {
console.error('[jlcpcb] Failed to reload database:', err.message);
}
});
// ── Price extraction ────────────────────────────────────────────
function extractPriceTiers(priceField) {
if (!priceField) return [];
try {
const parsed = typeof priceField === 'string' ? JSON.parse(priceField) : priceField;
if (!Array.isArray(parsed)) return [];
return parsed.map(t => ({
qty_from: t.qFrom ?? t.qty_from ?? null,
qty_to: t.qTo ?? t.qty_to ?? null,
price_usd: typeof t.price === 'number' ? Math.round(t.price * 10000) / 10000 : null,
}));
} catch {
return [];
}
}
function unitPrice(priceField) {
const tiers = extractPriceTiers(priceField);
if (tiers.length === 0) {
const n = Number(priceField);
return Number.isFinite(n) ? Math.round(n * 10000) / 10000 : null;
}
return tiers[0]?.price_usd ?? null;
}
// ── Extra field parsing ─────────────────────────────────────────
function parseExtra(extra) {
if (!extra) return {};
try {
return typeof extra === 'string' ? JSON.parse(extra) : extra;
} catch {
return {};
}
}
// ── Normalize a component row ───────────────────────────────────
function normalizeComponent(row) {
const extra = parseExtra(row.extra);
const result = {
lcsc: `C${row.lcsc}`,
lcsc_id: row.lcsc,
mfr: row.mfr || null,
description: extra?.description || row.description || null,
package: row.package || null,
stock: row.stock ?? 0,
is_basic: Boolean(row.basic),
is_preferred: Boolean(row.preferred),
joints: row.joints ?? null,
unit_price_usd: unitPrice(row.price),
price_tiers: extractPriceTiers(row.price),
datasheet: row.datasheet || extra?.datasheet?.url || null,
};
if (extra) {
if (extra.manufacturer?.name) result.manufacturer = extra.manufacturer.name;
if (extra.images) {
const imgs = Array.isArray(extra.images) ? extra.images : [extra.images];
const first = imgs[0];
if (first) result.image_url = first['900x900'] || first['224x224'] || first['96x96'] || null;
result.images = imgs.map(img => img['900x900'] || img['224x224'] || img['96x96']).filter(Boolean);
}
if (extra.attributes) result.attributes = extra.attributes;
if (extra.url) result.jlcpcb_url = extra.url;
result.jlcpcb_parts_url = `https://jlcpcb.com/parts/componentSearch?searchTxt=${result.lcsc}`;
}
return result;
}
// ── Popularity scoring ──────────────────────────────────────────
function popularityTier(stock) {
if (stock >= 50000) return 'very_high';
if (stock >= 10000) return 'high';
if (stock >= 2000) return 'medium';
if (stock >= 500) return 'low';
return 'very_low';
}
function enrichWithPopularity(component) {
return { ...component, popularity: popularityTier(component.stock) };
}
// ── Build dynamic WHERE for category_ids ────────────────────────
function categoryIdPlaceholders(ids) {
return ids.map(() => '?').join(',');
}
// ── Action handlers ─────────────────────────────────────────────
function handleSearch({ query, keyword, limit = 20, package: pkg, is_basic, is_preferred }) {
// Accept both `query` (historical) and `keyword` (matches the rest of
// the adom ecosystem: adom-mouser, adom-digikey, adom-parts-search all
// send `keyword`). Either works; `keyword` wins if both are set.
query = keyword || query;
const safeLimit = Math.min(Number(limit) || 20, 100);
const overFetch = safeLimit * 3;
let rows;
if (!query || !String(query).trim()) {
rows = db.prepare('SELECT * FROM components WHERE stock > 0 ORDER BY stock DESC LIMIT ?').all(safeLimit);
} else if (/^c\d+$/i.test(query.trim())) {
const lcscNum = parseInt(query.trim().replace(/^c/i, ''), 10);
const row = db.prepare('SELECT * FROM components WHERE lcsc = ? AND stock > 0').get(lcscNum);
rows = row ? [row] : [];
} else if (ftsTable === 'search_fts') {
// Use FTS5 for full-text search across mfr, package, description
// Strip characters that break FTS5 MATCH syntax, quote each token
const ftsQuery = query
.replace(/['"(){}[\]:!@#$%^&*+=|\\/<>~`]/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
.map(t => `"${t}"`)
.join(' ');
try {
rows = db.prepare(`
SELECT c.* FROM components c
JOIN search_fts fts ON c.rowid = fts.rowid
WHERE search_fts MATCH ? AND c.stock > 0
ORDER BY c.stock DESC LIMIT ?
`).all(ftsQuery, overFetch);
} catch {
rows = []; // FTS parse error — fall through to LIKE
}
// If FTS returns nothing, fall back to LIKE on mfr
if (rows.length === 0) {
const pattern = `%${query}%`;
rows = db.prepare(`
SELECT * FROM components
WHERE mfr LIKE ? AND stock > 0
ORDER BY stock DESC LIMIT ?
`).all(pattern, overFetch);
}
} else {
// No FTS table — fall back to LIKE on mfr
const pattern = `%${query}%`;
rows = db.prepare(`
SELECT * FROM components
WHERE mfr LIKE ? AND stock > 0
ORDER BY stock DESC LIMIT ?
`).all(pattern, overFetch);
}
if (pkg) rows = rows.filter(r => r.package?.includes(pkg));
if (is_basic === true || is_basic === 'true') rows = rows.filter(r => r.basic === 1);
if (is_preferred === true || is_preferred === 'true') rows = rows.filter(r => r.preferred === 1);
rows = rows.slice(0, safeLimit);
const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));
return { count: components.length, components };
}
function handleListComponents({ subcategory, search, keyword, package: pkg, limit = 20, is_basic, is_preferred }) {
search = keyword || search;
const safeLimit = Math.min(Number(limit) || 20, 100);
let sql = 'SELECT c.* FROM components c';
const params = [];
if (subcategory) {
sql += ' JOIN categories cat ON c.category_id = cat.id';
sql += ' WHERE c.stock > 0 AND cat.subcategory = ?';
params.push(subcategory);
} else {
sql += ' WHERE c.stock > 0';
}
if (search) {
sql += ' AND (c.mfr LIKE ? OR c.description LIKE ? OR CAST(c.lcsc AS TEXT) LIKE ?)';
const pattern = `%${search}%`;
params.push(pattern, pattern, pattern);
}
if (pkg) { sql += ' AND c.package = ?'; params.push(pkg); }
if (is_basic === true || is_basic === 'true') sql += ' AND c.basic = 1';
if (is_preferred === true || is_preferred === 'true') sql += ' AND c.preferred = 1';
sql += ' ORDER BY c.stock DESC LIMIT ?';
params.push(safeLimit);
const rows = db.prepare(sql).all(...params);
const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));
return { count: components.length, components };
}
function handleCategorySearch({ category, filters = {}, limit = 20 }) {
if (typeof filters === 'string') {
try { filters = JSON.parse(filters); } catch { filters = {}; }
}
const categoryIds = CATEGORY_MAP[category];
if (!categoryIds || categoryIds.length === 0) {
return handleListComponents({ subcategory: category, limit });
}
const safeLimit = Math.min(Number(limit) || 20, 100);
let sql = `SELECT * FROM components WHERE stock > 0 AND category_id IN (${categoryIdPlaceholders(categoryIds)})`;
const params = [...categoryIds];
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === '') continue;
if (key === 'package') {
sql += ' AND package = ?';
params.push(String(value));
} else if (key === 'is_basic') {
sql += ' AND basic = 1';
} else if (key === 'is_preferred') {
sql += ' AND preferred = 1';
} else {
sql += ' AND (mfr LIKE ? OR description LIKE ?)';
const pattern = `%${value}%`;
params.push(pattern, pattern);
}
}
sql += ' ORDER BY stock DESC LIMIT ?';
params.push(safeLimit);
const rows = db.prepare(sql).all(...params);
const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));
return { count: components.length, category, components };
}
function handleCategories() {
const rows = db.prepare(`
SELECT cat.category, cat.subcategory, COUNT(*) as count
FROM components c
JOIN categories cat ON c.category_id = cat.id
WHERE c.stock > 0
GROUP BY cat.category, cat.subcategory
ORDER BY cat.category, count DESC
`).all();
const hierarchy = {};
for (const r of rows) {
if (!hierarchy[r.category]) hierarchy[r.category] = [];
hierarchy[r.category].push({ name: r.subcategory, count: r.count });
}
return { categories: hierarchy };
}
function handleHealth() {
try {
const total = db.prepare('SELECT COUNT(*) as n FROM components').get().n;
const inStock = db.prepare('SELECT COUNT(*) as n FROM components WHERE stock > 0').get().n;
// DB freshness telemetry. The file mtime tracks the last atomic
// swap done by cron/update-db.sh, which pulls from
// https://cdfer.github.io/jlcpcb-parts-database/
// Expose age so operators don't need to SSH to answer "is the data
// recent?". If db_age_hours climbs past ~36 h the nightly cron is
// probably broken; past ~240 h CDFER's upstream may have stopped
// publishing (they scrape LCSC; scrapers break).
let dbLastModifiedIso = null;
let dbAgeHours = null;
let dbSizeBytes = null;
try {
const stat = fs.statSync(DB_PATH);
dbLastModifiedIso = stat.mtime.toISOString();
dbAgeHours = Math.round((Date.now() - stat.mtimeMs) / 36e5 * 10) / 10;
dbSizeBytes = stat.size;
} catch { /* DB path unreachable — ok/false already ran */ }
return {
ok: true,
source: 'local_sqlite',
upstream: 'https://cdfer.github.io/jlcpcb-parts-database/',
total_components: total,
in_stock: inStock,
fts_table: ftsTable || 'none',
db_path: DB_PATH,
db_last_modified_iso: dbLastModifiedIso,
db_age_hours: dbAgeHours,
db_size_bytes: dbSizeBytes,
refresh_cron: '0 4 * * * (daily 04:00 UTC)',
};
} catch (err) {
return { ok: false, source: 'local_sqlite', error: err.message };
}
}
// ── Landing page ────────────────────────────────────────────────
function landingPage(health, baseUrl) {
const n = (v) => (v ?? 0).toLocaleString();
const api = baseUrl;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JLCPCB Component Search API</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;padding:32px 24px}
.container{max-width:860px;margin:0 auto}
h1{font-size:24px;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:10px}
h1 .dot{width:10px;height:10px;border-radius:50%;background:${health.ok ? '#3fb950' : '#f85149'};flex-shrink:0}
.subtitle{color:#8b949e;font-size:14px;margin-bottom:28px}
.stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:32px}
.stat{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:14px 18px;min-width:140px;flex:1}
.stat .val{font-size:22px;font-weight:700;color:#58a6ff}
.stat .label{font-size:12px;color:#8b949e;margin-top:2px}
h2{font-size:16px;font-weight:600;margin:28px 0 12px;color:#e6edf3;border-bottom:1px solid #21262d;padding-bottom:8px}
.endpoint{background:#161b22;border:1px solid #21262d;border-radius:8px;margin-bottom:12px;overflow:hidden}
.ep-header{padding:12px 16px;display:flex;align-items:center;gap:10px;cursor:pointer}
.ep-header:hover{background:#1c2129}
.method{font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;font-family:monospace}
.method.get{background:#0d2818;color:#3fb950}
.method.post{background:#1c1e3a;color:#a371f7}
.ep-path{font-family:monospace;font-size:14px;color:#e6edf3}
.ep-desc{color:#8b949e;font-size:13px;margin-left:auto}
.ep-body{padding:0 16px 14px;display:none}
.ep-body.open{display:block}
table{width:100%;border-collapse:collapse;margin-top:8px}
th{text-align:left;font-size:11px;color:#8b949e;font-weight:600;padding:6px 8px;border-bottom:1px solid #21262d}
td{font-size:13px;padding:6px 8px;border-bottom:1px solid #161b22;vertical-align:top}
td:first-child{font-family:monospace;color:#79c0ff;white-space:nowrap}
.example{background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:10px 14px;font-family:monospace;font-size:13px;color:#8b949e;margin-top:10px;white-space:pre-wrap;word-break:break-all;position:relative}
.example .kw{color:#ff7b72}
.example .str{color:#a5d6ff}
.try-link{font-size:12px;color:#58a6ff;text-decoration:none;margin-left:8px}
.try-link:hover{text-decoration:underline}
.footer{margin-top:40px;padding-top:16px;border-top:1px solid #21262d;color:#484f58;font-size:12px;display:flex;justify-content:space-between}
.ai-examples{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
.ai-q{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:10px 14px;font-size:14px;color:#e6edf3;display:flex;align-items:center;gap:10px}
.ai-q .ai-icon{color:#d2a8ff;font-size:16px;flex-shrink:0}
a{color:#58a6ff;text-decoration:none}
a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="container">
<h1><span class="dot"></span> JLCPCB Component Search API</h1>
<p class="subtitle">Shared service for searching the JLCPCB/LCSC component catalog. Backed by a local SQLite database updated daily from <a href="https://github.com/cdfer/jlcpcb-parts-database">cdfer/jlcpcb-parts-database</a>.</p>
<div class="stats">
<div class="stat"><div class="val">${n(health.total_components)}</div><div class="label">Total components</div></div>
<div class="stat"><div class="val">${n(health.in_stock)}</div><div class="label">In stock</div></div>
<div class="stat"><div class="val">${health.fts_table === 'search_fts' ? 'FTS5' : 'LIKE'}</div><div class="label">Search engine</div></div>
<div class="stat"><div class="val">Daily</div><div class="label">Update frequency</div></div>
</div>
<h2>Endpoints</h2>
<div class="endpoint">
<div class="ep-header" onclick="toggle(this)">
<span class="method get">GET</span>
<span class="ep-path">/search</span>
<span class="ep-desc">Full-text component search</span>
</div>
<div class="ep-body">
<table>
<tr><th>Param</th><th>Type</th><th>Description</th></tr>
<tr><td>query</td><td>string</td><td>Search term — part number, MFR, description, or LCSC code (e.g. <code>C14663</code>)</td></tr>
<tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>
<tr><td>package</td><td>string</td><td>Filter by package (e.g. <code>0402</code>, <code>LQFP-48</code>)</td></tr>
<tr><td>is_basic</td><td>bool</td><td>Only JLCPCB basic parts</td></tr>
<tr><td>is_preferred</td><td>bool</td><td>Only JLCPCB preferred parts</td></tr>
</table>
<div class="example">curl "${api}/search?query=STM32&limit=5"</div>
</div>
</div>
<div class="endpoint">
<div class="ep-header" onclick="toggle(this)">
<span class="method get">GET</span>
<span class="ep-path">/list</span>
<span class="ep-desc">List components by subcategory</span>
</div>
<div class="ep-body">
<table>
<tr><th>Param</th><th>Type</th><th>Description</th></tr>
<tr><td>subcategory</td><td>string</td><td>Exact subcategory name from /categories</td></tr>
<tr><td>search</td><td>string</td><td>Filter within subcategory</td></tr>
<tr><td>package</td><td>string</td><td>Filter by package</td></tr>
<tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>
<tr><td>is_basic</td><td>bool</td><td>Only basic parts</td></tr>
<tr><td>is_preferred</td><td>bool</td><td>Only preferred parts</td></tr>
</table>
<div class="example">curl "${api}/list?subcategory=Microcontrollers%20(MCU/MPU/SOC)&limit=5"</div>
</div>
</div>
<div class="endpoint">
<div class="ep-header" onclick="toggle(this)">
<span class="method get">GET</span>
<span class="ep-path">/category_search</span>
<span class="ep-desc">Search within a named category</span>
</div>
<div class="ep-body">
<table>
<tr><th>Param</th><th>Type</th><th>Description</th></tr>
<tr><td>category</td><td>string</td><td>Category key: <code>resistors</code>, <code>capacitors</code>, <code>microcontrollers</code>, <code>mosfets</code>, <code>leds</code>, etc.</td></tr>
<tr><td>filters</td><td>JSON</td><td>Optional filters object: <code>{"package":"0402","is_basic":true}</code></td></tr>
<tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>
</table>
<div class="example">curl "${api}/category_search?category=capacitors&limit=5"</div>
</div>
</div>
<div class="endpoint">
<div class="ep-header" onclick="toggle(this)">
<span class="method get">GET</span>
<span class="ep-path">/categories</span>
<span class="ep-desc">List all categories and subcategories</span>
</div>
<div class="ep-body">
<p style="color:#8b949e;font-size:13px;margin-top:4px">Returns a hierarchy of category → subcategory with component counts.</p>
<div class="example">curl "${api}/categories"</div>
</div>
</div>
<div class="endpoint">
<div class="ep-header" onclick="toggle(this)">
<span class="method get">GET</span>
<span class="ep-path">/health</span>
<span class="ep-desc">Service health check</span>
</div>
<div class="ep-body">
<p style="color:#8b949e;font-size:13px;margin-top:4px">Returns JSON with <code>ok</code>, component counts, FTS status, and DB path.</p>
<div class="example">curl "${api}/health"</div>
</div>
</div>
<h2>AI Usage</h2>
<p style="color:#8b949e;font-size:13px;margin-bottom:12px">Adom containers with Adom Viewer installed have this service available as an MCP tool. Just ask Claude in natural language:</p>
<div class="ai-examples">
<div class="ai-q"><span class="ai-icon">◇</span> Find me a USB-C connector that's a JLCPCB basic part</div>
<div class="ai-q"><span class="ai-icon">◇</span> Search JLCPCB for STM32 microcontrollers in LQFP-48 package</div>
<div class="ai-q"><span class="ai-icon">◇</span> Look up LCSC part C14663 — what is it and how much does it cost?</div>
<div class="ai-q"><span class="ai-icon">◇</span> I need a 3.3V LDO regulator on JLCPCB, show me the cheapest basic parts</div>
<div class="ai-q"><span class="ai-icon">◇</span> Compare 0402 100nF capacitors on JLCPCB — show me basic parts sorted by price</div>
</div>
<h2>POST API</h2>
<p style="color:#8b949e;font-size:13px;margin-bottom:12px">All endpoints also accept POST with JSON body. Set <code>action</code> to the endpoint name.</p>
<div class="example"><span class="kw">curl</span> -X POST ${api} \\
-H <span class="str">"Content-Type: application/json"</span> \\
-d <span class="str">'{"action":"search","query":"ESP32","limit":5}'</span></div>
<h2>Response Format</h2>
<p style="color:#8b949e;font-size:13px;margin-bottom:8px">Each component in the results includes:</p>
<table>
<tr><th>Field</th><th>Description</th></tr>
<tr><td>lcsc</td><td>LCSC part number (e.g. C14663)</td></tr>
<tr><td>mfr</td><td>Manufacturer part number</td></tr>
<tr><td>description</td><td>Component description</td></tr>
<tr><td>package</td><td>Package type (0402, LQFP-48, etc.)</td></tr>
<tr><td>stock</td><td>Current stock quantity</td></tr>
<tr><td>is_basic</td><td>JLCPCB basic part (lowest assembly fee)</td></tr>
<tr><td>is_preferred</td><td>JLCPCB preferred part</td></tr>
<tr><td>unit_price_usd</td><td>Unit price in USD</td></tr>
<tr><td>price_tiers</td><td>Array of quantity-based pricing tiers</td></tr>
<tr><td>popularity</td><td>Stock-based tier: very_high, high, medium, low, very_low</td></tr>
<tr><td>datasheet</td><td>Datasheet URL (when available)</td></tr>
<tr><td>jlcpcb_parts_url</td><td>Link to JLCPCB parts page</td></tr>
<tr><td>attributes</td><td>Key-value component attributes (when available)</td></tr>
</table>
<div class="footer">
<span>JLCPCB Component Search Service · Adom</span>
<span>Data: <a href="https://github.com/cdfer/jlcpcb-parts-database">cdfer/jlcpcb-parts-database</a></span>
</div>
</div>
<script>
function toggle(el){el.nextElementSibling.classList.toggle('open')}
// Auto-open first endpoint
document.querySelector('.ep-body').classList.add('open');
</script>
</body>
</html>`;
}
// ── HTTP Server ─────────────────────────────────────────────────
const server = createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
let action, params;
if (req.method === 'POST') {
try {
const body = await new Promise((resolve, reject) => {
let data = '';
req.on('data', chunk => data += chunk);
req.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
req.on('error', reject);
});
action = body.action;
params = body;
delete params.action;
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON body' }));
return;
}
} else if (req.method === 'GET') {
const url = new URL(req.url, `http://localhost:${PORT}`);
const path = url.pathname.replace(/^\//, '');
// Serve HTML landing page at root for browsers
if (!path && req.headers.accept?.includes('text/html')) {
const health = handleHealth();
// Derive public base URL from request headers (proxy sets Host / X-Forwarded-*)
const proto = req.headers['x-forwarded-proto'] || 'https';
const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${PORT}`;
const prefix = req.headers['x-forwarded-prefix'] || '';
const baseUrl = host.includes('localhost') || host.includes('127.0.0.1')
? `http://${host}${prefix}`
: `${proto}://${host}${prefix}`;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(landingPage(health, baseUrl));
return;
}
action = path || 'health';
params = Object.fromEntries(url.searchParams);
} else {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
try {
let result;
switch (action) {
case 'search':
result = handleSearch(params);
break;
case 'list':
result = handleListComponents(params);
break;
case 'category_search':
result = handleCategorySearch(params);
break;
case 'categories':
result = handleCategories();
break;
case 'health':
result = handleHealth();
break;
default:
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
console.error(`[jlcpcb] Error handling ${action}:`, err.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`[jlcpcb] JLCPCB search server listening on 0.0.0.0:${PORT}`);
console.log(`[jlcpcb] Source: local SQLite (${DB_PATH})`);
console.log(`[jlcpcb] FTS: ${ftsTable || 'none (LIKE fallback)'}`);
});