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}`))