#!/usr/bin/env node /** * Local Morphus bridge for the Figma plugin. * The plugin UI sends HTML here, and this server returns the converted JSON. */ import http from 'node:http'; import { randomUUID } from 'node:crypto'; import { convertHtmlString } from '../src/pipeline/convert.js'; const PORT = Number.parseInt(process.env.PORT ?? process.env.MORPHUS_PORT ?? '3210', 10); const HOST = process.env.HOST ?? '0.0.0.0'; const jobs = new Map(); const server = http.createServer(async (req, res) => { setCors(res); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, host: HOST, port: PORT })); return; } if (req.method === 'POST' && req.url === '/jobs') { try { const body = await readJsonBody(req); if (!body.html || typeof body.html !== 'string') { throw new Error('`html` is required.'); } const jobId = randomUUID(); jobs.set(jobId, { state: 'queued', progress: 0, message: 'Queued', result: null, error: null, }); runJob(jobId, body).catch((error) => { setJobError(jobId, error); }); res.writeHead(202, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jobId })); return; } catch (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: error.message })); return; } } if (req.method === 'GET' && req.url && req.url.startsWith('/jobs/')) { const jobId = req.url.split('/').pop(); const job = jobs.get(jobId); if (!job) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Job not found' })); return; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(job)); return; } if (req.method === 'POST' && req.url === '/convert') { try { const body = await readJsonBody(req); if (!body.html || typeof body.html !== 'string') { throw new Error('`html` is required.'); } const result = await convertHtmlString(body.html, { sourceName: body.sourceName || 'inline.html', baseUrl: body.baseUrl || null, viewport: { width: body.viewport?.width, height: body.viewport?.height, }, }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; } catch (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: error.message })); return; } } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); }); server.listen(PORT, HOST, () => { const displayHost = HOST === '0.0.0.0' ? 'localhost' : HOST; console.log(`Morphus server listening on http://${displayHost}:${PORT}`); }); async function runJob(jobId, body) { setJob(jobId, 'running', 1, 'Starting conversion...'); const result = await convertHtmlString(body.html, { sourceName: body.sourceName || 'inline.html', baseUrl: body.baseUrl || null, viewport: { width: body.viewport && body.viewport.width, height: body.viewport && body.viewport.height, }, onProgress: (progress, message) => { setJob(jobId, 'running', progress, message); }, }); setJob(jobId, 'done', 100, 'Done', result); } function setJob(jobId, state, progress, message, result) { jobs.set(jobId, { state: state, progress: progress, message: message, result: result || null, error: null, }); } function setJobError(jobId, error) { jobs.set(jobId, { state: 'error', progress: 100, message: 'Conversion failed', result: null, error: error && error.message ? error.message : String(error), }); } function setCors(res) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); } function readJsonBody(req) { return new Promise((resolve, reject) => { let raw = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { raw += chunk; if (raw.length > 10 * 1024 * 1024) { reject(new Error('Request body too large.')); req.destroy(); } }); req.on('end', () => { try { resolve(JSON.parse(raw || '{}')); } catch { reject(new Error('Invalid JSON body.')); } }); req.on('error', reject); }); }