import { randomBytes, timingSafeEqual } from "node:crypto"; import { readFile, realpath } from "node:fs/promises"; import { createServer, type IncomingMessage, type Server, type ServerResponse, } from "node:http"; import { basename, dirname, extname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { CanvasAction, CanvasState, CanvasViewport } from "./store.js"; const BODY_LIMIT = 1024 * 1024; const MAX_CLIENTS = 8; const PUBLISH_DELAY_MS = 100; type Client = { response: ServerResponse; blocked: boolean }; export type CanvasServer = { url: string; publish(state: CanvasState): void; close(): Promise; }; export async function startCanvasServer( state: () => CanvasState, onAction: (action: CanvasAction) => void | Promise, ): Promise { const token = randomBytes(32).toString("base64url"); const browserRoot = await browserDirectory(); const clients = new Set(); const sockets = new Set(); let expectedHost = ""; let pending: CanvasState | undefined; let timer: ReturnType | undefined; let closePromise: Promise | undefined; let publish: (next: CanvasState) => void = () => undefined; const server = createServer((req, res) => { void dispatch(req, res, { token, expectedHost, state, onAction, browserRoot, clients, publish, }).catch(() => { if (!res.headersSent && !res.destroyed) json(res, 400, { error: "Invalid request." }); else res.destroy(); }); }); server.on("connection", (socket) => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }); await listen(server); const address = server.address(); if (!address || typeof address === "string") { server.close(); throw new Error("Canvas loopback bind failed."); } expectedHost = `127.0.0.1:${address.port}`; publish = (next: CanvasState): void => { pending = next; if (!timer) timer = setTimeout(flush, PUBLISH_DELAY_MS); }; function flush(): void { timer = undefined; const next = pending; pending = undefined; if (!next) return; let event: string; try { event = `event: state\ndata: ${JSON.stringify(next)}\n\n`; } catch { return; } for (const client of clients) sendEvent(client, event, clients); } return { url: `http://${expectedHost}/#${token}`, publish, close(): Promise { if (closePromise) return closePromise; if (timer) clearTimeout(timer); timer = undefined; pending = undefined; for (const client of clients) client.response.end(); clients.clear(); for (const socket of sockets) socket.destroy(); closePromise = closeServer(server); return closePromise; }, }; } type DispatchContext = { token: string; expectedHost: string; state: () => CanvasState; onAction: (action: CanvasAction) => void | Promise; browserRoot: string | undefined; clients: Set; publish: (state: CanvasState) => void; }; async function dispatch( req: IncomingMessage, res: ServerResponse, ctx: DispatchContext, ): Promise { if (req.headers.host !== ctx.expectedHost) return json(res, 400, { error: "Invalid Host header." }); const url = new URL(req.url ?? "/", `http://${ctx.expectedHost}`); if (url.pathname === "/") return req.method === "GET" ? page(res) : methodNotAllowed(res, "GET"); if (url.pathname === "/browser/config.js") return req.method === "GET" ? config(res) : methodNotAllowed(res, "GET"); if (url.pathname.startsWith("/browser/")) return serveAsset(req, res, url.pathname, ctx.browserRoot); if (url.pathname === "/api/state") { if (req.method !== "GET") return methodNotAllowed(res, "GET"); if (!authorized(req, ctx.token)) return unauthorized(res); return json(res, 200, ctx.state()); } if (url.pathname === "/api/events") { if (req.method !== "GET") return methodNotAllowed(res, "GET"); if (!authorized(req, ctx.token)) return unauthorized(res); if (ctx.clients.size >= MAX_CLIENTS) return json(res, 503, { error: "Too many canvas connections." }); return events(res, ctx.state, ctx.clients); } if (url.pathname === "/api/action") { if (req.method !== "POST") return methodNotAllowed(res, "POST"); if (!authorized(req, ctx.token)) return unauthorized(res); if (req.headers.origin !== `http://${ctx.expectedHost}`) return json(res, 403, { error: "Invalid Origin header." }); try { const action = validateAction(parseJson(await bodyText(req))); await ctx.onAction(action); const next = ctx.state(); ctx.publish(next); return json(res, 200, { ok: true, state: next }); } catch (error) { return json(res, isHttpError(error) ? error.status : 409, { error: safeError(error), }); } } return json(res, 404, { error: "Not found." }); } async function browserDirectory(): Promise { const here = dirname(fileURLToPath(import.meta.url)); const candidate = resolve( here, basename(here) === "src" ? "../browser" : "browser", ); try { return await realpath(candidate); } catch { return undefined; } } async function serveAsset( req: IncomingMessage, res: ServerResponse, pathname: string, root: string | undefined, ): Promise { if (req.method !== "GET") return methodNotAllowed(res, "GET"); if (!root) return json(res, 404, { error: "Not found." }); const name = pathname.slice("/browser/".length); if ( !name || name .split("/") .some( (part) => !/^[A-Za-z0-9._-]+$/u.test(part) || part === "." || part === "..", ) ) return json(res, 404, { error: "Not found." }); const extension = extname(name); if (extension !== ".js" && extension !== ".css" && extension !== ".woff2") return json(res, 404, { error: "Not found." }); try { const file = await realpath(resolve(root, name)); if (relative(root, file).startsWith("..")) return json(res, 404, { error: "Not found." }); asset(res, await readFile(file), extension); } catch { json(res, 404, { error: "Not found." }); } } function validateAction(value: unknown): CanvasAction { if (!record(value) || typeof value["kind"] !== "string") throw requestError("Invalid action."); switch (value["kind"]) { case "scene": fields(value, ["kind", "revision", "elements", "viewport"]); if (!revision(value["revision"])) throw requestError("Invalid scene revision."); return { kind: "scene", revision: value["revision"], elements: value["elements"], ...(value["viewport"] === undefined ? {} : { viewport: viewport(value["viewport"]) }), }; case "prepare": fields(value, ["kind", "requestId", "clientId", "additions"]); return { kind: "prepare", requestId: identifier(value["requestId"], "request"), clientId: identifier(value["clientId"], "client"), additions: value["additions"], }; case "commit": fields(value, ["kind", "requestId", "clientId", "revision", "elements"]); if (!revision(value["revision"])) throw requestError("Invalid drawing revision."); return { kind: "commit", requestId: identifier(value["requestId"], "request"), clientId: identifier(value["clientId"], "client"), revision: value["revision"], elements: value["elements"], }; case "cancel": fields(value, ["kind", "requestId", "clientId"]); return { kind: "cancel", requestId: identifier(value["requestId"], "request"), clientId: identifier(value["clientId"], "client"), }; default: throw requestError("Unknown action."); } } function viewport(value: unknown): CanvasViewport { if (!record(value)) throw requestError("Invalid viewport image."); fields(value, [ "data", "mimeType", "imageWidth", "imageHeight", "width", "height", "scrollX", "scrollY", "zoom", ]); const data = value["data"]; const mimeType = value["mimeType"]; if ( typeof data !== "string" || !data.length || data.length > 256 * 1024 || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(data) || (mimeType !== "image/png" && mimeType !== "image/jpeg") ) throw requestError("Invalid viewport image."); const bytes = Buffer.from(data, "base64"); if ( mimeType === "image/png" ? !bytes .subarray(0, 8) .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) : bytes[0] !== 255 || bytes[1] !== 216 || bytes[2] !== 255 ) throw requestError("Invalid viewport image signature."); for (const key of [ "imageWidth", "imageHeight", "width", "height", "scrollX", "scrollY", "zoom", ]) { if (typeof value[key] !== "number" || !Number.isFinite(value[key])) throw requestError("Invalid viewport geometry."); } for (const key of ["imageWidth", "imageHeight"]) if ( !Number.isInteger(value[key]) || Number(value[key]) < 1 || Number(value[key]) > 1024 ) throw requestError("Invalid viewport image dimensions."); for (const key of ["width", "height"]) if (Number(value[key]) <= 0 || Number(value[key]) > 16384) throw requestError("Invalid viewport dimensions."); if ( Number(value["zoom"]) < 0.1 || Number(value["zoom"]) > 30 || Math.abs(Number(value["scrollX"])) > 1e9 || Math.abs(Number(value["scrollY"])) > 1e9 ) throw requestError("Invalid viewport transform."); return value as CanvasViewport; } function fields(value: Record, allowed: string[]): void { if (Object.keys(value).some((key) => !allowed.includes(key))) throw requestError("Action has unknown fields."); } function revision(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } function identifier(value: unknown, type: string): string { if (typeof value !== "string" || !value.trim() || value.length > 120) throw requestError(`Invalid ${type} ID.`); return value; } function record(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function requestError(message: string): Error & { status: number } { return Object.assign(new Error(message), { status: 400 }); } function parseJson(value: string): unknown { try { return JSON.parse(value); } catch { throw requestError("Invalid JSON."); } } function isHttpError(error: unknown): error is Error & { status: number } { const status = error instanceof Error ? (error as { status?: unknown }).status : undefined; return ( typeof status === "number" && Number.isInteger(status) && status >= 400 && status < 500 ); } function safeError(error: unknown): string { if (!(error instanceof Error)) return "Action was rejected."; const message = error.message.replace(/[\r\n\t]/gu, " ").trim(); return message && message.length <= 500 ? message : "Action was rejected."; } function events( res: ServerResponse, state: () => CanvasState, clients: Set, ): void { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", }); const client = { response: res, blocked: false }; clients.add(client); try { sendEvent( client, `event: state\ndata: ${JSON.stringify(state())}\n\n`, clients, ); } catch { res.end(); } res.once("close", () => clients.delete(client)); } function sendEvent(client: Client, event: string, clients: Set): void { if (client.blocked) { client.response.destroy(); clients.delete(client); return; } if (!client.response.write(event)) { client.blocked = true; client.response.once("drain", () => { client.blocked = false; }); } } function authorized(req: IncomingMessage, token: string): boolean { const value = req.headers.authorization; if (!value?.startsWith("Bearer ")) return false; const supplied = Buffer.from(value.slice(7)); const expected = Buffer.from(token); return ( supplied.length === expected.length && timingSafeEqual(supplied, expected) ); } async function bodyText(req: IncomingMessage): Promise { const chunks: Buffer[] = []; let size = 0; for await (const chunk of req) { const bytes = Buffer.from(chunk); size += bytes.length; if (size > BODY_LIMIT) throw Object.assign(new Error("Request body exceeds 1 MiB."), { status: 413, }); chunks.push(bytes); } return Buffer.concat(chunks).toString("utf8"); } function page(res: ServerResponse): void { res.writeHead(200, securityHeaders("text/html; charset=utf-8")); res.end( 'Pi canvas
', ); } function config(res: ServerResponse): void { res.writeHead(200, securityHeaders("text/javascript; charset=utf-8")); res.end('globalThis.EXCALIDRAW_ASSET_PATH="/browser/";'); } function json(res: ServerResponse, status: number, value: unknown): void { res.writeHead(status, securityHeaders("application/json; charset=utf-8")); res.end(JSON.stringify(value)); } function unauthorized(res: ServerResponse): void { res.writeHead(401, { ...securityHeaders("application/json; charset=utf-8"), "WWW-Authenticate": "Bearer", }); res.end('{"error":"Unauthorized."}'); } function methodNotAllowed(res: ServerResponse, allow: string): void { res.writeHead(405, { ...securityHeaders("application/json; charset=utf-8"), Allow: allow, }); res.end('{"error":"Method not allowed."}'); } function asset(res: ServerResponse, body: Buffer, extension: string): void { res.writeHead( 200, securityHeaders( extension === ".css" ? "text/css; charset=utf-8" : extension === ".woff2" ? "font/woff2" : "text/javascript; charset=utf-8", ), ); res.end(body); } function securityHeaders(contentType: string): Record { return { "Content-Type": contentType, "Cache-Control": "no-store", "Content-Security-Policy": "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self' data:; img-src 'self' data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", }; } function listen(server: Server): Promise { return new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); } function closeServer(server: Server): Promise { return new Promise((resolve) => server.close(() => resolve())); }