File size: 10,720 Bytes
6f91ce5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | 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}`))
|