/**
* HTTP routes for the EasyEDA Component Browser
* Handles serving the HTML page and proxying API requests
*
* Returns KiCad S-expression strings for symbol/footprint preview rendering
*/
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import type { IncomingMessage, ServerResponse } from 'http'
import { createLogger } from '../utils/logger.js'
import type { EasyEDAComponentData, EasyEDACommunityComponent } from '../types/index.js'
import { easyedaCommunityClient } from '../api/easyeda-community.js'
import { symbolConverter } from '../converter/symbol.js'
import { footprintConverter } from '../converter/footprint.js'
const logger = createLogger('http-routes')
// Get the directory of this file to find assets
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Cache the HTML content in memory
let htmlCache: string | null = null
/**
* Load the HTML page from the assets directory
*/
function getHtmlPage(): string {
if (htmlCache) return htmlCache
try {
// When bundled, __dirname points to dist/ since everything is in index.js
// When running from source, __dirname is src/http/
const possiblePaths = [
// Bundled: dist/index.js looking for dist/assets/search.html
join(__dirname, 'assets/search.html'),
// Bundled alternative path
join(__dirname, '../dist/assets/search.html'),
// Source: src/http/ looking for src/assets/
join(__dirname, '../assets/search.html'),
join(__dirname, '../assets/search-built.html'),
// From project root (core package)
join(process.cwd(), 'dist/assets/search.html'),
join(process.cwd(), 'packages/core/dist/assets/search.html'),
// When imported from other packages
join(__dirname, '../../dist/assets/search.html'),
// When bundled into CLI or other package, look for core's assets relative to monorepo
join(__dirname, '../../../core/dist/assets/search.html'),
join(__dirname, '../../../../packages/core/dist/assets/search.html'),
// Look in node_modules if installed as dependency
join(__dirname, '../../node_modules/@jlcpcb/core/dist/assets/search.html'),
]
for (const path of possiblePaths) {
try {
htmlCache = readFileSync(path, 'utf-8')
logger.debug(`Loaded HTML from: ${path}`)
return htmlCache
} catch {
// Try next path
}
}
throw new Error('HTML file not found')
} catch (error) {
logger.error('Failed to load HTML page:', error)
return `
Error
Error: Search page not found
The search page has not been built. Run: bun run build
`
}
}
/**
* Main request handler
*/
export async function handleRequest(
req: IncomingMessage,
res: ServerResponse
): Promise {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
const pathname = url.pathname
logger.debug(`${req.method} ${pathname}`)
// Route requests
if (pathname === '/' || pathname === '/index.html') {
const query = url.searchParams.get('q') || undefined
serveHtml(res, query)
} else if (pathname === '/api/search') {
await handleSearch(url, res)
} else if (pathname.startsWith('/api/component/')) {
const uuid = pathname.replace('/api/component/', '')
await handleComponent(uuid, res)
} else if (pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'ok' }))
} else {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
}
}
/**
* Serve the HTML page with optional initial query injection
*/
function serveHtml(res: ServerResponse, initialQuery?: string): void {
let html = getHtmlPage()
if (initialQuery) {
// Inject initial query via a script tag before
const script = ``
html = html.replace('', `${script}`)
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(html)
}
/**
* Handle search API requests
* GET /api/search?q=query&source=user|lcsc|all&page=1&limit=20
*/
async function handleSearch(
url: URL,
res: ServerResponse
): Promise {
const query = url.searchParams.get('q') || ''
const source = url.searchParams.get('source') || 'user'
const page = parseInt(url.searchParams.get('page') || '1', 10)
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
if (!query) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing query parameter' }))
return
}
try {
// Fetch more than we need for pagination (EasyEDA doesn't support offset)
const allResults = await easyedaCommunityClient.search({
query,
source: source as 'user' | 'lcsc' | 'easyeda' | 'all',
limit: Math.min(limit * page + limit, 100), // Max 100 results
})
// Calculate pagination
const startIndex = (page - 1) * limit
const endIndex = startIndex + limit
const results = allResults.slice(startIndex, endIndex)
const totalPages = Math.ceil(allResults.length / limit)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
results,
pagination: {
page,
limit,
total: allResults.length,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
}))
} catch (error) {
logger.error('Search error:', error)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Search failed' }))
}
}
/**
* Convert EasyEDACommunityComponent to EasyEDAComponentData format
* Required because converters expect the LCSC-style structure
*/
function communityToComponentData(
community: EasyEDACommunityComponent
): EasyEDAComponentData {
// Extract component info from the head c_para
const cPara = community.symbol.head?.c_para as Record | undefined ?? {}
const fpCPara = community.footprint.head?.c_para as Record | undefined ?? {}
return {
info: {
name: community.title || cPara.name || 'Unknown',
prefix: cPara.pre || cPara.Prefix || 'U',
package: fpCPara.package || community.footprint.name,
manufacturer: cPara.Manufacturer || cPara.BOM_Manufacturer,
description: community.description || cPara.BOM_Manufacturer_Part,
category: cPara.package, // Best guess for category
},
symbol: community.symbol,
footprint: community.footprint,
model3d: community.model3d,
rawData: community.rawData,
}
}
/**
* Handle component fetch API requests
* GET /api/component/:uuid
*
* Returns KiCad S-expression strings for browser-side rendering
*/
async function handleComponent(
uuid: string,
res: ServerResponse
): Promise {
if (!uuid) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing UUID' }))
return
}
try {
const component = await easyedaCommunityClient.getComponent(uuid)
if (!component) {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Component not found' }))
return
}
// Convert community component to converter-compatible format
const componentData = communityToComponentData(component)
// Generate KiCad S-expression strings using existing converters
let symbolSexpr = ''
let footprintSexpr = ''
try {
// Get just the symbol entry (not the full library wrapper)
symbolSexpr = symbolConverter.convertToSymbolEntry(componentData)
} catch (e) {
logger.warn('Symbol conversion failed:', e)
}
try {
footprintSexpr = footprintConverter.convert(componentData)
} catch (e) {
logger.warn('Footprint conversion failed:', e)
}
// Return S-expr strings for browser rendering
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
uuid: component.uuid,
title: component.title,
description: component.description,
owner: component.owner,
symbolSexpr,
footprintSexpr,
model3d: component.model3d,
}))
} catch (error) {
logger.error('Component fetch error:', error)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Failed to fetch component' }))
}
}