repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/canvas/src/server.ts
Rawimport { 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<void>;
};
export async function startCanvasServer(
state: () => CanvasState,
onAction: (action: CanvasAction) => void | Promise<void>,
): Promise<CanvasServer> {
const token = randomBytes(32).toString("base64url");
const browserRoot = await browserDirectory();
const clients = new Set<Client>();
const sockets = new Set<import("node:net").Socket>();
let expectedHost = "";
let pending: CanvasState | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
let closePromise: Promise<void> | 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<void> {
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<void>;
browserRoot: string | undefined;
clients: Set<Client>;
publish: (state: CanvasState) => void;
};
async function dispatch(
req: IncomingMessage,
res: ServerResponse,
ctx: DispatchContext,
): Promise<void> {
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<string | undefined> {
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<void> {
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<string, unknown>, 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<string, unknown> {
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<Client>,
): 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<Client>): 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<string> {
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(
'<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Pi canvas</title><link rel="stylesheet" href="/browser/excalidraw.css"><link rel="stylesheet" href="/browser/style.css"></head><body><div id="root"></div><script src="/browser/config.js"></script><script type="module" src="/browser/main.js"></script></body></html>',
);
}
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<string, string> {
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<void> {
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<void> {
return new Promise((resolve) => server.close(() => resolve()));
}