import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { extname, join, resolve, sep } from 'node:path' import type { Duplex } from 'node:stream' import { pipeline } from 'node:stream/promises' import { unfurl } from 'unfurl.js' import { WebSocketServer } from 'ws' import { handleAcpUpgrade, onAgentPresence } from './acpPipe.ts' import { handleApi } from './api.ts' import { createDemoBoard, DEMO_MODE, demoBoards } from './demo.ts' import { handleCanvasMcp } from './canvasMcp.ts' import { recordAgentPresence } from './events.ts' import { publicFetch } from './netGuard.ts' import { getRoom, HttpError, patchShapes, putShapes, safeId } from './rooms.ts' // The sync server: one TLSocketRoom per room (rooms.ts), each saved to its own SQLite file under // DATA_DIR, plus the canvas MCP, the REST and SSE API with its board DB (api.ts), asset uploads on // local disk, bookmark unfurling, and the built frontend from dist/. const PORT = Number(process.env.PORT ?? 5858) const DATA_DIR = resolve(process.env.DATA_DIR ?? '.data') const UPLOADS_DIR = join(DATA_DIR, 'uploads') const DIST_DIR = resolve('dist') mkdirSync(UPLOADS_DIR, { recursive: true }) onAgentPresence(recordAgentPresence) function send(res: ServerResponse, status: number, body: string) { if (!res.headersSent) res.writeHead(status, { 'content-type': 'text/plain' }) res.end(body) } function sendJson(res: ServerResponse, body: unknown) { res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify(body)) } // we only allow image and video uploads. the content type is kept next to the file. async function handleUpload(uploadId: string, req: IncomingMessage, res: ServerResponse) { const contentType = req.headers['content-type'] ?? '' if (!contentType.startsWith('image/') && !contentType.startsWith('video/')) { return send(res, 400, 'Invalid content type') } const file = join(UPLOADS_DIR, safeId(uploadId)) // a file without its .type marker is a leftover from an upload that never finished if (existsSync(file) && !existsSync(`${file}.type`)) rmSync(file, { force: true }) try { await pipeline(req, createWriteStream(file, { flags: 'wx' })) } catch (e) { if ((e as NodeJS.ErrnoException).code === 'EEXIST') return send(res, 409, 'Upload already exists') rmSync(file, { force: true }) throw e } writeFileSync(`${file}.type`, contentType) sendJson(res, { ok: true }) } // ponytail: no Range support, so videos can't seek before they've fully loaded async function handleDownload(uploadId: string, res: ServerResponse) { const file = join(UPLOADS_DIR, safeId(uploadId)) // the .type file is written last, so this only serves finished uploads if (!existsSync(`${file}.type`)) return send(res, 404, 'Not found') res.writeHead(200, { 'content-type': readFileSync(`${file}.type`, 'utf8'), // assets are immutable, so we can cache them basically forever: 'cache-control': 'public, max-age=31536000, immutable', 'access-control-allow-origin': '*', // Prevent XSS from user-uploaded SVGs (or any file served with an executable content-type). 'content-security-policy': "default-src 'none'", 'x-content-type-options': 'nosniff', }) await pipeline(createReadStream(file), res) } // bookmarks need to extract metadata from pasted URLs. Every fetch (the page and its oEmbed // link) goes through publicFetch, so a pasted URL can't make the server call a private address. const UNFURL_HEADERS = { Accept: 'text/html, application/xhtml+xml', 'User-Agent': 'facebookexternalhit' } async function handleUnfurl(url: string | null, res: ServerResponse) { if (!url?.match(/^https?:\/\//)) return send(res, 400, 'Bad URL query parameter.') try { const { title, description, favicon, open_graph, twitter_card } = await unfurl(url, { fetch: (target) => publicFetch(target, { headers: UNFURL_HEADERS }), }) const ogImage = open_graph?.images?.[0] sendJson(res, { title, description, favicon, image: ogImage?.url ?? twitter_card?.images?.[0]?.url, imageWidth: ogImage?.width, imageHeight: ogImage?.height, }) } catch { send(res, 422, 'Failed to fetch URL.') } } const MIME: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2', } // the built frontend; unknown paths get index.html so client-side routes like /:roomId work async function handleStatic(pathname: string, res: ServerResponse) { let file = join(DIST_DIR, decodeURIComponent(pathname)) if (!file.startsWith(DIST_DIR + sep) || !existsSync(file) || statSync(file).isDirectory()) { file = join(DIST_DIR, 'index.html') } if (!existsSync(file)) return send(res, 404, 'No frontend build found. Run `pnpm build` first.') res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' }) await pipeline(createReadStream(file), res) } // Bad input is the client's fault (400 with the reason); anything else is ours (500, logged). async function handleShapes(roomId: string, req: IncomingMessage, res: ServerResponse) { try { const body = JSON.parse(Buffer.concat(await req.toArray()).toString()) if (!Array.isArray(body)) throw new HttpError(400, 'Expected a JSON array of shapes and bindings') return sendJson(res, { ids: req.method === 'PATCH' ? patchShapes(roomId, body) : putShapes(roomId, body) }) } catch (e) { if (e instanceof HttpError) return send(res, e.status, e.message) if (e instanceof SyntaxError || (e instanceof Error && e.name === 'ValidationError')) { return send(res, 400, String(e)) } console.error(e) return send(res, 500, 'Internal error') } } function parseUrl(raw: string | undefined) { try { return new URL(raw ?? '/', 'http://localhost') } catch { return null } } // the pitch video plays in-app in demo mode; the client only shows it when the file is there function demoVideoUrl() { const file = process.env.DEMO_VIDEO return DEMO_MODE && file && existsSync(file) ? '/api/demo/video' : null } // Safari won't play an mp4 it can't seek, so Range requests are required, not optional. async function handleDemoVideo(req: IncomingMessage, res: ServerResponse) { const file = process.env.DEMO_VIDEO if (!file || !existsSync(file)) return send(res, 404, 'Not found') const size = statSync(file).size const headers = { 'content-type': 'video/mp4', 'accept-ranges': 'bytes' } const range = req.headers.range?.match(/^bytes=(\d*)-(\d*)$/) if (!range) { res.writeHead(200, { ...headers, 'content-length': size }) return await pipeline(createReadStream(file), res) } const [, from, to] = range const start = from === '' ? Math.max(0, size - Number(to)) : Number(from) const end = from === '' || to === '' ? size - 1 : Math.min(Number(to), size - 1) if (from === '' && to === '' || start > end || start >= size) { res.writeHead(416, { ...headers, 'content-range': `bytes */${size}` }) return res.end() } res.writeHead(206, { ...headers, 'content-length': end - start + 1, 'content-range': `bytes ${start}-${end}/${size}` }) await pipeline(createReadStream(file, { start, end }), res) } const server = createServer(async (req, res) => { try { const url = parseUrl(req.url) if (!url) return send(res, 400, 'Bad request') const path = url.pathname let m: RegExpMatchArray | null if (req.method === 'GET' && path === '/api/config') { return sendJson(res, { demo: DEMO_MODE, videoUrl: demoVideoUrl(), boards: DEMO_MODE ? demoBoards() : [] }) } if (DEMO_MODE) { if (req.method === 'GET' && path === '/api/demo/new') { res.writeHead(302, { location: `/${createDemoBoard(url.searchParams.get('board'))}`, 'cache-control': 'no-store' }) return res.end() } if (req.method === 'GET' && path === '/api/demo/video') return await handleDemoVideo(req, res) // the Start/new-board flow needs the live agent; in demo visitors go to the demo Home if (req.method === 'GET' && (path === '/landing/index.html' || path === '/new')) { res.writeHead(302, { location: '/landing/home.html', 'cache-control': 'no-store' }) return res.end() } // a public demo can't host anonymous files if (req.method === 'POST' && path.match(/^\/api\/uploads\/([^/]+)$/)) return send(res, 403, 'Uploads are off in the demo') } if ((m = path.match(/^\/api\/uploads\/([^/]+)$/))) { if (req.method === 'POST') return await handleUpload(m[1], req, res) if (req.method === 'GET') return await handleDownload(m[1], res) } if (req.method === 'GET' && path === '/api/unfurl') { return await handleUnfurl(url.searchParams.get('url'), res) } if ((req.method === 'POST' || req.method === 'PATCH') && (m = path.match(/^\/api\/rooms\/([^/]+)\/shapes$/))) { return await handleShapes(m[1], req, res) } if ((m = path.match(/^\/api\/mcp\/canvas\/([^/]+)$/))) return await handleCanvasMcp(req, res, m[1]) if (path.startsWith('/api/v1/')) return await handleApi(req, res, url) if (path.startsWith('/api/')) return send(res, 404, 'Not found') await handleStatic(path, res) } catch (e) { console.error(e) send(res, 500, 'Internal error') } }) function rejectUpgrade(socket: Duplex, status: number, reason: string) { socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) socket.destroy() } // realtime sync: the client opens a websocket at /api/connect/:roomId?sessionId=... // Nothing here may throw: an uncaught error would take down every room on the server. const wss = new WebSocketServer({ noServer: true }) server.on('upgrade', (req, socket, head) => { try { const url = parseUrl(req.url) if (url?.pathname === '/api/acp') { // demo mode never spawns a harness if (DEMO_MODE) return rejectUpgrade(socket, 503, 'Service Unavailable') return handleAcpUpgrade(req, socket, head) } const roomId = url?.pathname.match(/^\/api\/connect\/([^/]+)$/)?.[1] const sessionId = url?.searchParams.get('sessionId') if (!roomId || !sessionId) return rejectUpgrade(socket, 400, 'Bad Request') wss.handleUpgrade(req, socket, head, (ws) => { try { getRoom(roomId).handleSocketConnect({ sessionId, socket: ws }) } catch (e) { console.error(`Room ${safeId(roomId)} could not be opened:`, e) ws.close(1011, 'Room unavailable') } }) } catch (e) { console.error('Bad websocket upgrade:', e) rejectUpgrade(socket, 400, 'Bad Request') } }) server.listen(PORT, () => console.log(`sync server on http://localhost:${PORT}, data in ${DATA_DIR}`))