@jlcpcb/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +241 -0
- package/debug-text.ts +24 -0
- package/dist/assets/search.html +528 -0
- package/dist/index.js +32364 -0
- package/dist/src/index.js +28521 -0
- package/package.json +49 -0
- package/scripts/build-search-page.ts +68 -0
- package/src/assets/search-built.html +528 -0
- package/src/assets/search.html +458 -0
- package/src/browser/index.ts +381 -0
- package/src/browser/kicad-renderer.ts +646 -0
- package/src/browser/sexpr-parser.ts +321 -0
- package/src/http/routes.ts +253 -0
- package/src/http/server.ts +74 -0
- package/src/index.ts +117 -0
- package/src/tools/details.ts +66 -0
- package/src/tools/easyeda.ts +582 -0
- package/src/tools/index.ts +98 -0
- package/src/tools/library-fix.ts +414 -0
- package/src/tools/library-update.ts +412 -0
- package/src/tools/library.ts +263 -0
- package/src/tools/search.ts +58 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S-Expression Parser for KiCad format
|
|
3
|
+
* Parses KiCad symbol and footprint S-expressions for browser-side rendering
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type SExpr = string | SExpr[]
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tokenize S-expression input into tokens
|
|
10
|
+
* Handles parentheses, quoted strings, and atoms
|
|
11
|
+
*/
|
|
12
|
+
function tokenize(input: string): string[] {
|
|
13
|
+
const tokens: string[] = []
|
|
14
|
+
let i = 0
|
|
15
|
+
|
|
16
|
+
while (i < input.length) {
|
|
17
|
+
const char = input[i]
|
|
18
|
+
|
|
19
|
+
// Skip whitespace
|
|
20
|
+
if (/\s/.test(char)) {
|
|
21
|
+
i++
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Opening/closing parens
|
|
26
|
+
if (char === '(' || char === ')') {
|
|
27
|
+
tokens.push(char)
|
|
28
|
+
i++
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Quoted string
|
|
33
|
+
if (char === '"') {
|
|
34
|
+
let str = ''
|
|
35
|
+
i++ // skip opening quote
|
|
36
|
+
while (i < input.length) {
|
|
37
|
+
if (input[i] === '\\' && i + 1 < input.length) {
|
|
38
|
+
// Escape sequence
|
|
39
|
+
str += input[i + 1]
|
|
40
|
+
i += 2
|
|
41
|
+
} else if (input[i] === '"') {
|
|
42
|
+
i++ // skip closing quote
|
|
43
|
+
break
|
|
44
|
+
} else {
|
|
45
|
+
str += input[i]
|
|
46
|
+
i++
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
tokens.push(`"${str}"`)
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Atom (unquoted token)
|
|
54
|
+
let atom = ''
|
|
55
|
+
while (i < input.length && !/[\s()]/.test(input[i])) {
|
|
56
|
+
atom += input[i]
|
|
57
|
+
i++
|
|
58
|
+
}
|
|
59
|
+
if (atom) {
|
|
60
|
+
tokens.push(atom)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return tokens
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse tokens into nested S-expression structure
|
|
69
|
+
*/
|
|
70
|
+
function parseTokens(tokens: string[]): { expr: SExpr; remaining: string[] } {
|
|
71
|
+
if (tokens.length === 0) {
|
|
72
|
+
return { expr: [], remaining: [] }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const token = tokens[0]
|
|
76
|
+
|
|
77
|
+
if (token === '(') {
|
|
78
|
+
// Start of list
|
|
79
|
+
const list: SExpr[] = []
|
|
80
|
+
let rest = tokens.slice(1)
|
|
81
|
+
|
|
82
|
+
while (rest.length > 0 && rest[0] !== ')') {
|
|
83
|
+
const { expr, remaining } = parseTokens(rest)
|
|
84
|
+
list.push(expr)
|
|
85
|
+
rest = remaining
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Skip closing paren
|
|
89
|
+
if (rest[0] === ')') {
|
|
90
|
+
rest = rest.slice(1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { expr: list, remaining: rest }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (token === ')') {
|
|
97
|
+
// Unexpected closing paren - return empty and let caller handle
|
|
98
|
+
return { expr: [], remaining: tokens.slice(1) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Atom or quoted string
|
|
102
|
+
let value = token
|
|
103
|
+
// Strip quotes from quoted strings for easier access
|
|
104
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
105
|
+
value = value.slice(1, -1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { expr: value, remaining: tokens.slice(1) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse S-expression string into nested structure
|
|
113
|
+
*/
|
|
114
|
+
export function parseSExpr(input: string): SExpr {
|
|
115
|
+
const tokens = tokenize(input)
|
|
116
|
+
const { expr } = parseTokens(tokens)
|
|
117
|
+
return expr
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if expression is a list
|
|
122
|
+
*/
|
|
123
|
+
export function isList(expr: SExpr): expr is SExpr[] {
|
|
124
|
+
return Array.isArray(expr)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if expression is an atom (string)
|
|
129
|
+
*/
|
|
130
|
+
export function isAtom(expr: SExpr): expr is string {
|
|
131
|
+
return typeof expr === 'string'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the first element of a list (the tag/type)
|
|
136
|
+
*/
|
|
137
|
+
export function getTag(expr: SExpr): string | undefined {
|
|
138
|
+
if (isList(expr) && expr.length > 0 && isAtom(expr[0])) {
|
|
139
|
+
return expr[0]
|
|
140
|
+
}
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Find a child element by its tag name
|
|
146
|
+
* Returns first match or undefined
|
|
147
|
+
*/
|
|
148
|
+
export function findChild(expr: SExpr, tag: string): SExpr[] | undefined {
|
|
149
|
+
if (!isList(expr)) return undefined
|
|
150
|
+
|
|
151
|
+
for (const child of expr) {
|
|
152
|
+
if (isList(child) && getTag(child) === tag) {
|
|
153
|
+
return child
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Find all child elements with a given tag
|
|
161
|
+
*/
|
|
162
|
+
export function findChildren(expr: SExpr, tag: string): SExpr[][] {
|
|
163
|
+
if (!isList(expr)) return []
|
|
164
|
+
|
|
165
|
+
const results: SExpr[][] = []
|
|
166
|
+
for (const child of expr) {
|
|
167
|
+
if (isList(child) && getTag(child) === tag) {
|
|
168
|
+
results.push(child)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return results
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get a simple attribute value: (tag value) -> value
|
|
176
|
+
*/
|
|
177
|
+
export function getAttr(expr: SExpr, tag: string): string | undefined {
|
|
178
|
+
const child = findChild(expr, tag)
|
|
179
|
+
if (child && child.length >= 2 && isAtom(child[1])) {
|
|
180
|
+
return child[1]
|
|
181
|
+
}
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get a numeric attribute value
|
|
187
|
+
*/
|
|
188
|
+
export function getNumericAttr(expr: SExpr, tag: string): number | undefined {
|
|
189
|
+
const value = getAttr(expr, tag)
|
|
190
|
+
if (value !== undefined) {
|
|
191
|
+
const num = parseFloat(value)
|
|
192
|
+
if (!isNaN(num)) return num
|
|
193
|
+
}
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get a point from (tag x y) format
|
|
199
|
+
*/
|
|
200
|
+
export function getPoint(
|
|
201
|
+
expr: SExpr,
|
|
202
|
+
tag: string
|
|
203
|
+
): { x: number; y: number } | undefined {
|
|
204
|
+
const child = findChild(expr, tag)
|
|
205
|
+
if (child && child.length >= 3) {
|
|
206
|
+
const x = parseFloat(isAtom(child[1]) ? child[1] : '')
|
|
207
|
+
const y = parseFloat(isAtom(child[2]) ? child[2] : '')
|
|
208
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
209
|
+
return { x, y }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get point with optional rotation: (tag x y [rotation])
|
|
217
|
+
*/
|
|
218
|
+
export function getPointWithRotation(
|
|
219
|
+
expr: SExpr,
|
|
220
|
+
tag: string
|
|
221
|
+
): { x: number; y: number; rotation?: number } | undefined {
|
|
222
|
+
const child = findChild(expr, tag)
|
|
223
|
+
if (child && child.length >= 3) {
|
|
224
|
+
const x = parseFloat(isAtom(child[1]) ? child[1] : '')
|
|
225
|
+
const y = parseFloat(isAtom(child[2]) ? child[2] : '')
|
|
226
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
227
|
+
const result: { x: number; y: number; rotation?: number } = { x, y }
|
|
228
|
+
if (child.length >= 4 && isAtom(child[3])) {
|
|
229
|
+
const rotation = parseFloat(child[3])
|
|
230
|
+
if (!isNaN(rotation)) {
|
|
231
|
+
result.rotation = rotation
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return result
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get size from (size w h) format
|
|
242
|
+
*/
|
|
243
|
+
export function getSize(
|
|
244
|
+
expr: SExpr
|
|
245
|
+
): { width: number; height: number } | undefined {
|
|
246
|
+
const child = findChild(expr, 'size')
|
|
247
|
+
if (child && child.length >= 3) {
|
|
248
|
+
const width = parseFloat(isAtom(child[1]) ? child[1] : '')
|
|
249
|
+
const height = parseFloat(isAtom(child[2]) ? child[2] : '')
|
|
250
|
+
if (!isNaN(width) && !isNaN(height)) {
|
|
251
|
+
return { width, height }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get stroke properties from (stroke ...) element
|
|
259
|
+
*/
|
|
260
|
+
export function getStroke(
|
|
261
|
+
expr: SExpr
|
|
262
|
+
): { width: number; type: string } | undefined {
|
|
263
|
+
const stroke = findChild(expr, 'stroke')
|
|
264
|
+
if (!stroke) return undefined
|
|
265
|
+
|
|
266
|
+
const width = getNumericAttr(stroke, 'width') ?? 0.254
|
|
267
|
+
const type = getAttr(stroke, 'type') ?? 'default'
|
|
268
|
+
|
|
269
|
+
return { width, type }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get fill type from (fill (type ...)) element
|
|
274
|
+
*/
|
|
275
|
+
export function getFillType(expr: SExpr): string | undefined {
|
|
276
|
+
const fill = findChild(expr, 'fill')
|
|
277
|
+
if (!fill) return undefined
|
|
278
|
+
return getAttr(fill, 'type')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get all XY points from (pts (xy x y) (xy x y) ...) structure
|
|
283
|
+
*/
|
|
284
|
+
export function getPoints(expr: SExpr): Array<{ x: number; y: number }> {
|
|
285
|
+
const pts = findChild(expr, 'pts')
|
|
286
|
+
if (!pts) return []
|
|
287
|
+
|
|
288
|
+
const points: Array<{ x: number; y: number }> = []
|
|
289
|
+
for (const child of pts) {
|
|
290
|
+
if (isList(child) && getTag(child) === 'xy' && child.length >= 3) {
|
|
291
|
+
const x = parseFloat(isAtom(child[1]) ? child[1] : '')
|
|
292
|
+
const y = parseFloat(isAtom(child[2]) ? child[2] : '')
|
|
293
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
294
|
+
points.push({ x, y })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return points
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get layers from (layers "Layer1" "Layer2" ...) or (layer "Layer")
|
|
304
|
+
*/
|
|
305
|
+
export function getLayers(expr: SExpr): string[] {
|
|
306
|
+
// Check for single layer
|
|
307
|
+
const layer = getAttr(expr, 'layer')
|
|
308
|
+
if (layer) return [layer]
|
|
309
|
+
|
|
310
|
+
// Check for multiple layers
|
|
311
|
+
const layers = findChild(expr, 'layers')
|
|
312
|
+
if (!layers) return []
|
|
313
|
+
|
|
314
|
+
const result: string[] = []
|
|
315
|
+
for (let i = 1; i < layers.length; i++) {
|
|
316
|
+
if (isAtom(layers[i])) {
|
|
317
|
+
result.push(layers[i])
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return result
|
|
321
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes for the EasyEDA Component Browser
|
|
3
|
+
* Handles serving the HTML page and proxying API requests
|
|
4
|
+
*
|
|
5
|
+
* Returns KiCad S-expression strings for symbol/footprint preview rendering
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'fs'
|
|
9
|
+
import { join, dirname } from 'path'
|
|
10
|
+
import { fileURLToPath } from 'url'
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
12
|
+
import {
|
|
13
|
+
createLogger,
|
|
14
|
+
type EasyEDAComponentData,
|
|
15
|
+
type EasyEDACommunityComponent,
|
|
16
|
+
easyedaCommunityClient,
|
|
17
|
+
symbolConverter,
|
|
18
|
+
footprintConverter,
|
|
19
|
+
} from '@jlcpcb/core'
|
|
20
|
+
|
|
21
|
+
const logger = createLogger('http-routes')
|
|
22
|
+
|
|
23
|
+
// Get the directory of this file to find assets
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
25
|
+
const __dirname = dirname(__filename)
|
|
26
|
+
|
|
27
|
+
// Cache the HTML content in memory
|
|
28
|
+
let htmlCache: string | null = null
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load the HTML page from the assets directory
|
|
32
|
+
*/
|
|
33
|
+
function getHtmlPage(): string {
|
|
34
|
+
if (htmlCache) return htmlCache
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// When bundled, __dirname points to dist/ since everything is in index.js
|
|
38
|
+
// When running from source, __dirname is src/http/
|
|
39
|
+
const possiblePaths = [
|
|
40
|
+
// Bundled: dist/index.js looking for dist/assets/search.html
|
|
41
|
+
join(__dirname, 'assets/search.html'),
|
|
42
|
+
// Bundled alternative path
|
|
43
|
+
join(__dirname, '../dist/assets/search.html'),
|
|
44
|
+
// Source: src/http/ looking for src/assets/
|
|
45
|
+
join(__dirname, '../assets/search.html'),
|
|
46
|
+
join(__dirname, '../assets/search-built.html'),
|
|
47
|
+
// From project root
|
|
48
|
+
join(process.cwd(), 'dist/assets/search.html'),
|
|
49
|
+
join(process.cwd(), 'packages/jlc-mcp/dist/assets/search.html'),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
for (const path of possiblePaths) {
|
|
53
|
+
try {
|
|
54
|
+
htmlCache = readFileSync(path, 'utf-8')
|
|
55
|
+
logger.debug(`Loaded HTML from: ${path}`)
|
|
56
|
+
return htmlCache
|
|
57
|
+
} catch {
|
|
58
|
+
// Try next path
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new Error('HTML file not found')
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logger.error('Failed to load HTML page:', error)
|
|
65
|
+
return `<!DOCTYPE html>
|
|
66
|
+
<html>
|
|
67
|
+
<head><title>Error</title></head>
|
|
68
|
+
<body>
|
|
69
|
+
<h1>Error: Search page not found</h1>
|
|
70
|
+
<p>The search page has not been built. Run: bun run build</p>
|
|
71
|
+
</body>
|
|
72
|
+
</html>`
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Main request handler
|
|
78
|
+
*/
|
|
79
|
+
export async function handleRequest(
|
|
80
|
+
req: IncomingMessage,
|
|
81
|
+
res: ServerResponse
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
|
|
84
|
+
const pathname = url.pathname
|
|
85
|
+
|
|
86
|
+
logger.debug(`${req.method} ${pathname}`)
|
|
87
|
+
|
|
88
|
+
// Route requests
|
|
89
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
90
|
+
serveHtml(res)
|
|
91
|
+
} else if (pathname === '/api/search') {
|
|
92
|
+
await handleSearch(url, res)
|
|
93
|
+
} else if (pathname.startsWith('/api/component/')) {
|
|
94
|
+
const uuid = pathname.replace('/api/component/', '')
|
|
95
|
+
await handleComponent(uuid, res)
|
|
96
|
+
} else if (pathname === '/health') {
|
|
97
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
98
|
+
res.end(JSON.stringify({ status: 'ok' }))
|
|
99
|
+
} else {
|
|
100
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
101
|
+
res.end(JSON.stringify({ error: 'Not found' }))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Serve the HTML page
|
|
107
|
+
*/
|
|
108
|
+
function serveHtml(res: ServerResponse): void {
|
|
109
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
110
|
+
res.end(getHtmlPage())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Handle search API requests
|
|
115
|
+
* GET /api/search?q=query&source=user|lcsc|all&page=1&limit=20
|
|
116
|
+
*/
|
|
117
|
+
async function handleSearch(
|
|
118
|
+
url: URL,
|
|
119
|
+
res: ServerResponse
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const query = url.searchParams.get('q') || ''
|
|
122
|
+
const source = url.searchParams.get('source') || 'user'
|
|
123
|
+
const page = parseInt(url.searchParams.get('page') || '1', 10)
|
|
124
|
+
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
|
|
125
|
+
|
|
126
|
+
if (!query) {
|
|
127
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
128
|
+
res.end(JSON.stringify({ error: 'Missing query parameter' }))
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
// Fetch more than we need for pagination (EasyEDA doesn't support offset)
|
|
134
|
+
const allResults = await easyedaCommunityClient.search({
|
|
135
|
+
query,
|
|
136
|
+
source: source as 'user' | 'lcsc' | 'easyeda' | 'all',
|
|
137
|
+
limit: Math.min(limit * page + limit, 100), // Max 100 results
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Calculate pagination
|
|
141
|
+
const startIndex = (page - 1) * limit
|
|
142
|
+
const endIndex = startIndex + limit
|
|
143
|
+
const results = allResults.slice(startIndex, endIndex)
|
|
144
|
+
const totalPages = Math.ceil(allResults.length / limit)
|
|
145
|
+
|
|
146
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
147
|
+
res.end(JSON.stringify({
|
|
148
|
+
results,
|
|
149
|
+
pagination: {
|
|
150
|
+
page,
|
|
151
|
+
limit,
|
|
152
|
+
total: allResults.length,
|
|
153
|
+
totalPages,
|
|
154
|
+
hasNext: page < totalPages,
|
|
155
|
+
hasPrev: page > 1,
|
|
156
|
+
},
|
|
157
|
+
}))
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger.error('Search error:', error)
|
|
160
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
161
|
+
res.end(JSON.stringify({ error: 'Search failed' }))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Convert EasyEDACommunityComponent to EasyEDAComponentData format
|
|
167
|
+
* Required because converters expect the LCSC-style structure
|
|
168
|
+
*/
|
|
169
|
+
function communityToComponentData(
|
|
170
|
+
community: EasyEDACommunityComponent
|
|
171
|
+
): EasyEDAComponentData {
|
|
172
|
+
// Extract component info from the head c_para
|
|
173
|
+
const cPara = community.symbol.head?.c_para as Record<string, string> | undefined ?? {}
|
|
174
|
+
const fpCPara = community.footprint.head?.c_para as Record<string, string> | undefined ?? {}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
info: {
|
|
178
|
+
name: community.title || cPara.name || 'Unknown',
|
|
179
|
+
prefix: cPara.pre || cPara.Prefix || 'U',
|
|
180
|
+
package: fpCPara.package || community.footprint.name,
|
|
181
|
+
manufacturer: cPara.Manufacturer || cPara.BOM_Manufacturer,
|
|
182
|
+
description: community.description || cPara.BOM_Manufacturer_Part,
|
|
183
|
+
category: cPara.package, // Best guess for category
|
|
184
|
+
},
|
|
185
|
+
symbol: community.symbol,
|
|
186
|
+
footprint: community.footprint,
|
|
187
|
+
model3d: community.model3d,
|
|
188
|
+
rawData: community.rawData,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Handle component fetch API requests
|
|
194
|
+
* GET /api/component/:uuid
|
|
195
|
+
*
|
|
196
|
+
* Returns KiCad S-expression strings for browser-side rendering
|
|
197
|
+
*/
|
|
198
|
+
async function handleComponent(
|
|
199
|
+
uuid: string,
|
|
200
|
+
res: ServerResponse
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
if (!uuid) {
|
|
203
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
204
|
+
res.end(JSON.stringify({ error: 'Missing UUID' }))
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const component = await easyedaCommunityClient.getComponent(uuid)
|
|
210
|
+
|
|
211
|
+
if (!component) {
|
|
212
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
213
|
+
res.end(JSON.stringify({ error: 'Component not found' }))
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Convert community component to converter-compatible format
|
|
218
|
+
const componentData = communityToComponentData(component)
|
|
219
|
+
|
|
220
|
+
// Generate KiCad S-expression strings using existing converters
|
|
221
|
+
let symbolSexpr = ''
|
|
222
|
+
let footprintSexpr = ''
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
// Get just the symbol entry (not the full library wrapper)
|
|
226
|
+
symbolSexpr = symbolConverter.convertToSymbolEntry(componentData)
|
|
227
|
+
} catch (e) {
|
|
228
|
+
logger.warn('Symbol conversion failed:', e)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
footprintSexpr = footprintConverter.convert(componentData)
|
|
233
|
+
} catch (e) {
|
|
234
|
+
logger.warn('Footprint conversion failed:', e)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Return S-expr strings for browser rendering
|
|
238
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
239
|
+
res.end(JSON.stringify({
|
|
240
|
+
uuid: component.uuid,
|
|
241
|
+
title: component.title,
|
|
242
|
+
description: component.description,
|
|
243
|
+
owner: component.owner,
|
|
244
|
+
symbolSexpr,
|
|
245
|
+
footprintSexpr,
|
|
246
|
+
model3d: component.model3d,
|
|
247
|
+
}))
|
|
248
|
+
} catch (error) {
|
|
249
|
+
logger.error('Component fetch error:', error)
|
|
250
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
251
|
+
res.end(JSON.stringify({ error: 'Failed to fetch component' }))
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server for the EasyEDA Component Browser
|
|
3
|
+
* Runs alongside the MCP stdio server to serve the web UI and proxy API requests
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
|
|
7
|
+
import { createLogger } from '@jlcpcb/core'
|
|
8
|
+
import { handleRequest } from './routes.js'
|
|
9
|
+
|
|
10
|
+
const logger = createLogger('http-server')
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PORT = 3847
|
|
13
|
+
|
|
14
|
+
let serverInstance: ReturnType<typeof createServer> | null = null
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Start the HTTP server
|
|
18
|
+
* @returns The port the server is listening on
|
|
19
|
+
*/
|
|
20
|
+
export function startHttpServer(): number {
|
|
21
|
+
if (serverInstance) {
|
|
22
|
+
logger.debug('HTTP server already running')
|
|
23
|
+
return DEFAULT_PORT
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const port = parseInt(process.env.JLC_MCP_HTTP_PORT || String(DEFAULT_PORT), 10)
|
|
27
|
+
|
|
28
|
+
serverInstance = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
29
|
+
// Add CORS headers to all responses
|
|
30
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
31
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
32
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
33
|
+
|
|
34
|
+
// Handle preflight requests
|
|
35
|
+
if (req.method === 'OPTIONS') {
|
|
36
|
+
res.writeHead(204)
|
|
37
|
+
res.end()
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await handleRequest(req, res)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
logger.error('Request error:', error)
|
|
45
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
46
|
+
res.end(JSON.stringify({ error: 'Internal server error' }))
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
serverInstance.listen(port, () => {
|
|
51
|
+
logger.info(`HTTP server listening on http://localhost:${port}`)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
serverInstance.on('error', (error: NodeJS.ErrnoException) => {
|
|
55
|
+
if (error.code === 'EADDRINUSE') {
|
|
56
|
+
logger.warn(`Port ${port} already in use, HTTP server not started`)
|
|
57
|
+
} else {
|
|
58
|
+
logger.error('HTTP server error:', error)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
return port
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stop the HTTP server
|
|
67
|
+
*/
|
|
68
|
+
export function stopHttpServer(): void {
|
|
69
|
+
if (serverInstance) {
|
|
70
|
+
serverInstance.close()
|
|
71
|
+
serverInstance = null
|
|
72
|
+
logger.info('HTTP server stopped')
|
|
73
|
+
}
|
|
74
|
+
}
|