pinned / server /server.ts
igorktech's picture
Pinned demo: two recorded boards, agent off
6f91ce5 verified
Raw History Blame Contribute Delete
10.7 kB
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<string, string> = {
'.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}`))