@frebreco/canvas 0.3.0 → 0.4.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frebreco/canvas",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-next.2",
4
4
  "description": "A multiplayer canvas for coding agents that run on your machine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -3,71 +3,131 @@
3
3
  * canvas — a multiplayer canvas for coding agents that run on your machine.
4
4
  *
5
5
  * canvas serve [--dir .] [--port 4418] [--web-url URL] [--tls-host NAME]
6
+ * [--relay URL] [--relay-key NAME:SECRET] [--relay-via transport|signal]
7
+ * canvas relay [--port 4419] [--host 0.0.0.0] [--cert FILE --key FILE]
6
8
  *
7
- * Starts the local server and prints the link that opens the board as its
8
- * host. `--tls-host` serves wss:// on that name (with `.certs/dev.{crt,key}`)
9
- * so a browser on another device can be the host; without it
10
- * the server only listens on loopback.
9
+ * `serve` starts the local server and prints the link that opens the board as
10
+ * its host. `--tls-host` serves wss:// on that name (with `.certs/dev.{crt,key}`)
11
+ * so a browser on another device can be the host; without it the server only
12
+ * listens on loopback. `--relay` puts the board on a `canvas relay` (ADR 0008)
13
+ * with the issuer key its operator gave you, for everything or only for
14
+ * peers to meet (`--relay-via signal`). Each relay flag has an environment
15
+ * variable: `CANVAS_RELAY`, `CANVAS_RELAY_KEY`, `CANVAS_RELAY_VIA`.
16
+ *
17
+ * `relay` runs a relay for boards on networks where peers can't connect
18
+ * directly; its settings are in `server/relay-config.ts`.
11
19
  */
12
20
 
13
21
  import { existsSync } from "node:fs";
14
22
  import { join, resolve } from "node:path";
15
23
  import { parseArgs } from "node:util";
16
- import { serve } from "./server/server";
17
- import { defaultWebUrl } from "./server/web-url";
18
24
 
19
- const manifest = (await Bun.file(join(import.meta.dir, "../package.json")).json()) as {
20
- version?: string;
21
- };
25
+ const USAGE = `usage: canvas serve [--dir .] [--port 4418] [--web-url URL] [--tls-host NAME]
26
+ [--relay URL] [--relay-key NAME:SECRET] [--relay-via transport|signal]
27
+ canvas relay [--port 4419] [--host 0.0.0.0] [--cert FILE --key FILE]`;
28
+
29
+ const [command, ...args] = process.argv.slice(2);
30
+ if (command === "relay") {
31
+ // Only what the relay needs: no agents, no terminals.
32
+ const { runRelay } = await import("./server/relay-cli");
33
+ runRelay(args, process.env);
34
+ } else if (command === "serve") {
35
+ await serveBoard(args);
36
+ } else {
37
+ console.log(USAGE);
38
+ process.exit(command === undefined ? 0 : 1);
39
+ }
22
40
 
23
- const { positionals, values } = parseArgs({
24
- allowPositionals: true,
25
- options: {
26
- dir: { type: "string", default: "." },
27
- port: { type: "string", default: "4418" },
28
- "web-url": {
29
- type: "string",
30
- default: process.env.CANVAS_WEB_URL ?? defaultWebUrl(manifest.version),
41
+ async function serveBoard(args: string[]) {
42
+ const { serve } = await import("./server/server");
43
+ const { defaultWebUrl } = await import("./server/web-url");
44
+ const { parseIssuer } = await import("./server/relay-token");
45
+ const manifest = (await Bun.file(join(import.meta.dir, "../package.json")).json()) as {
46
+ version?: string;
47
+ };
48
+ const env = process.env;
49
+ const { values } = parseArgs({
50
+ args,
51
+ options: {
52
+ dir: { type: "string", default: "." },
53
+ port: { type: "string", default: "4418" },
54
+ "web-url": { type: "string", default: env.CANVAS_WEB_URL ?? defaultWebUrl(manifest.version) },
55
+ "tls-host": { type: "string" },
56
+ cert: { type: "string", default: join(import.meta.dir, "../.certs/dev.crt") },
57
+ key: { type: "string", default: join(import.meta.dir, "../.certs/dev.key") },
58
+ relay: { type: "string", default: env.CANVAS_RELAY },
59
+ "relay-key": { type: "string", default: env.CANVAS_RELAY_KEY },
60
+ "relay-via": { type: "string", default: env.CANVAS_RELAY_VIA ?? "transport" },
31
61
  },
32
- "tls-host": { type: "string" },
33
- cert: { type: "string", default: join(import.meta.dir, "../.certs/dev.crt") },
34
- key: { type: "string", default: join(import.meta.dir, "../.certs/dev.key") },
35
- },
36
- });
62
+ });
63
+ const fail = (message: string) => {
64
+ console.error(message);
65
+ process.exit(1);
66
+ };
37
67
 
38
- if (positionals[0] !== "serve") {
39
- console.log("usage: canvas serve [--dir .] [--port 4418] [--web-url URL] [--tls-host NAME]");
40
- process.exit(positionals.length === 0 ? 0 : 1);
41
- }
68
+ const dir = resolve(values.dir);
69
+ const tlsHost = values["tls-host"];
70
+ if (tlsHost && !(existsSync(values.cert) && existsSync(values.key)))
71
+ fail(
72
+ `--tls-host needs a certificate: tailscale cert --cert-file ${values.cert} --key-file ${values.key} ${tlsHost}`,
73
+ );
42
74
 
43
- const dir = resolve(values.dir);
44
- const tlsHost = values["tls-host"];
45
- if (tlsHost && !(existsSync(values.cert) && existsSync(values.key))) {
46
- console.error(
47
- `--tls-host needs a certificate: tailscale cert --cert-file ${values.cert} --key-file ${values.key} ${tlsHost}`,
48
- );
49
- process.exit(1);
50
- }
75
+ let relay = null;
76
+ if (values.relay) {
77
+ const via = values["relay-via"];
78
+ if (via !== "transport" && via !== "signal") fail("--relay-via is transport or signal");
79
+ if (!/^wss?:\/\//.test(values.relay)) fail("--relay is the relay's ws:// or wss:// URL");
80
+ // The web app is https: browsers only allow ws:// to their own machine.
81
+ if (
82
+ /^ws:\/\//.test(values.relay) &&
83
+ !/^ws:\/\/(localhost|127\.0\.0\.1|\[::1\])[:/]/.test(values.relay)
84
+ )
85
+ console.warn(
86
+ " warning: browsers on the https web app only reach a relay over wss:// (ws:// only on their own machine)",
87
+ );
88
+ if (!values["relay-key"])
89
+ fail(
90
+ "--relay needs --relay-key NAME:SECRET (or CANVAS_RELAY_KEY), from the relay's operator",
91
+ );
92
+ let issuer;
93
+ try {
94
+ issuer = parseIssuer(values["relay-key"]!);
95
+ } catch (error) {
96
+ fail(`--relay-key: ${error instanceof Error ? error.message : String(error)}`);
97
+ }
98
+ relay = {
99
+ url: values.relay.replace(/\/$/, ""),
100
+ via: via as "transport" | "signal",
101
+ issuer: issuer!,
102
+ };
103
+ }
51
104
 
52
- const { server, room } = await serve({
53
- dir,
54
- port: Number(values.port),
55
- hostname: tlsHost ? "0.0.0.0" : "127.0.0.1",
56
- ...(tlsHost && { tls: { cert: values.cert, key: values.key } }),
57
- ...(manifest.version && { version: manifest.version }),
58
- });
105
+ const { server, room } = await serve({
106
+ dir,
107
+ port: Number(values.port),
108
+ hostname: tlsHost ? "0.0.0.0" : "127.0.0.1",
109
+ ...(tlsHost && { tls: { cert: values.cert, key: values.key } }),
110
+ ...(manifest.version && { version: manifest.version }),
111
+ ...(relay && { relay }),
112
+ });
59
113
 
60
- const serverUrl = tlsHost ? `wss://${tlsHost}:${server.port}` : `ws://127.0.0.1:${server.port}`;
61
- // Secrets ride in the fragment, which browsers never send to the web host.
62
- const fragment = new URLSearchParams({
63
- k: room.key,
64
- pk: room.hostPublicKey,
65
- server: serverUrl,
66
- token: room.token,
67
- });
68
- const link = `${values["web-url"].replace(/\/$/, "")}/?room=${room.roomId}#${fragment}`;
114
+ const serverUrl = tlsHost ? `wss://${tlsHost}:${server.port}` : `ws://127.0.0.1:${server.port}`;
115
+ // Secrets ride in the fragment, which browsers never send to the web host.
116
+ // The relay isn't named here: the host tab gets it from `canvas serve`.
117
+ const fragment = new URLSearchParams({
118
+ k: room.key,
119
+ pk: room.hostPublicKey,
120
+ server: serverUrl,
121
+ token: room.token,
122
+ });
123
+ const link = `${values["web-url"].replace(/\/$/, "")}/?room=${room.roomId}#${fragment}`;
69
124
 
70
- console.log(`canvas serving ${dir}`);
71
- console.log(`\n open the board as host:\n ${link}\n`);
72
- console.log(" keep this link to yourself — it controls agents on this machine.");
73
- console.log(" share the guest link from the board instead.\n");
125
+ console.log(`canvas serving ${dir}`);
126
+ console.log(`\n open the board as host:\n ${link}\n`);
127
+ console.log(" keep this link to yourself — it controls agents on this machine.");
128
+ console.log(" share the guest link from the board instead.\n");
129
+ if (relay)
130
+ console.log(
131
+ ` peers ${relay.via === "transport" ? "connect" : "meet"} through ${relay.url} (${relay.via}).\n`,
132
+ );
133
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `canvas relay` (ADR 0008): its flags and environment (`relay-config.ts`),
3
+ * then the server. Also the entry of the Docker image (`relay-main.ts`), so it
4
+ * pulls in nothing `canvas serve` needs.
5
+ */
6
+
7
+ import { parseArgs } from "node:util";
8
+
9
+ import { startRelay } from "./relay";
10
+ import { relayConfig } from "./relay-config";
11
+
12
+ export const RELAY_USAGE =
13
+ "canvas relay [--port 4419] [--host 0.0.0.0] [--cert FILE --key FILE] (CANVAS_RELAY_KEYS=name:secret,…)";
14
+
15
+ export function runRelay(args: string[], env: Readonly<Record<string, string | undefined>>) {
16
+ const { values } = parseArgs({
17
+ args,
18
+ options: {
19
+ port: { type: "string" },
20
+ host: { type: "string" },
21
+ cert: { type: "string" },
22
+ key: { type: "string" },
23
+ },
24
+ });
25
+ let config;
26
+ try {
27
+ config = relayConfig(env, values);
28
+ } catch (error) {
29
+ console.error(error instanceof Error ? error.message : String(error));
30
+ console.error(`usage: ${RELAY_USAGE}`);
31
+ process.exit(1);
32
+ }
33
+ const server = startRelay({ ...config, log: (line) => console.log(line) });
34
+ const scheme = config.tls ? "wss" : "ws";
35
+ console.log(
36
+ `canvas relay on ${scheme}://${config.hostname}:${server.port} for ${[...config.issuers.keys()].join(", ")}`,
37
+ );
38
+ const { limits } = config;
39
+ console.log(
40
+ ` limits: ${limits.peersPerRoom} peers a room, ${limits.connectionsPerIssuer} connections an issuer, ` +
41
+ `${limits.maxMessage / 1024 / 1024} MB a message, ${limits.messagesPerSecond} messages and ` +
42
+ `${limits.bytesPerSecond / 1024 / 1024} MB a second a connection`,
43
+ );
44
+ if (!config.tls) console.log(" no TLS here: put it behind a proxy that serves wss://");
45
+ // Containers stop with SIGTERM: close the connections, peers reconnect elsewhere or later.
46
+ for (const signal of ["SIGTERM", "SIGINT"] as const)
47
+ process.on(signal, () => {
48
+ void server.stop(true).then(() => process.exit(0));
49
+ });
50
+ return server;
51
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `canvas relay`'s settings (ADR 0008): environment variables, which a flag
3
+ * overrides where there is one. Issuer keys only come from the environment:
4
+ * flags show in process lists.
5
+ *
6
+ * CANVAS_RELAY_KEYS name:secret[,name:secret] required
7
+ * CANVAS_RELAY_PORT --port 4419
8
+ * CANVAS_RELAY_HOST --host 0.0.0.0
9
+ * CANVAS_RELAY_TLS_CERT --cert serve wss:// itself (else put it behind a TLS proxy)
10
+ * CANVAS_RELAY_TLS_KEY --key
11
+ * CANVAS_RELAY_MAX_PEERS 64 peers in one room
12
+ * CANVAS_RELAY_MAX_CONNECTIONS 256 open connections per issuer
13
+ * CANVAS_RELAY_MAX_MESSAGE_MB 64 one message (a whole agent thread goes in one)
14
+ * CANVAS_RELAY_RATE 500 messages per second per connection
15
+ * CANVAS_RELAY_BANDWIDTH_MB 20 MB per second per connection
16
+ */
17
+
18
+ import { parseIssuers } from "./relay-token";
19
+
20
+ export interface RelayConfig {
21
+ readonly port: number;
22
+ readonly hostname: string;
23
+ readonly issuers: ReadonlyMap<string, string>;
24
+ readonly tls?: { readonly cert: string; readonly key: string };
25
+ readonly limits: RelayLimits;
26
+ }
27
+
28
+ export interface RelayLimits {
29
+ readonly peersPerRoom: number;
30
+ readonly connectionsPerIssuer: number;
31
+ /** Bytes. */
32
+ readonly maxMessage: number;
33
+ /** Messages per second, per connection; bursts up to 4 s worth. */
34
+ readonly messagesPerSecond: number;
35
+ /** Bytes per second, per connection; bursts up to 4 s worth. */
36
+ readonly bytesPerSecond: number;
37
+ }
38
+
39
+ export const DEFAULT_LIMITS: RelayLimits = {
40
+ peersPerRoom: 64,
41
+ connectionsPerIssuer: 256,
42
+ maxMessage: 64 * 1024 * 1024,
43
+ messagesPerSecond: 500,
44
+ bytesPerSecond: 20 * 1024 * 1024,
45
+ };
46
+
47
+ export interface RelayFlags {
48
+ readonly port?: string;
49
+ readonly host?: string;
50
+ readonly cert?: string;
51
+ readonly key?: string;
52
+ }
53
+
54
+ type Env = Readonly<Record<string, string | undefined>>;
55
+
56
+ export function relayConfig(env: Env, flags: RelayFlags = {}): RelayConfig {
57
+ const issuers = parseIssuers(env.CANVAS_RELAY_KEYS ?? "");
58
+ if (issuers.size === 0)
59
+ throw new Error("canvas relay needs issuer keys: CANVAS_RELAY_KEYS=name:secret[,name:secret]");
60
+ const cert = flags.cert ?? env.CANVAS_RELAY_TLS_CERT;
61
+ const key = flags.key ?? env.CANVAS_RELAY_TLS_KEY;
62
+ if (Boolean(cert) !== Boolean(key))
63
+ throw new Error("TLS needs both a certificate and its key (CANVAS_RELAY_TLS_CERT and _KEY)");
64
+ const number = (name: string, fallback: number, scale = 1) => {
65
+ const text = env[name];
66
+ if (text === undefined || text === "") return Math.round(fallback * scale);
67
+ const value = Number(text);
68
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
69
+ return Math.round(value * scale);
70
+ };
71
+ const MB = 1024 * 1024;
72
+ return {
73
+ port: Number(flags.port ?? env.CANVAS_RELAY_PORT ?? 4419),
74
+ hostname: flags.host ?? env.CANVAS_RELAY_HOST ?? "0.0.0.0",
75
+ issuers,
76
+ ...(cert && key && { tls: { cert, key } }),
77
+ limits: {
78
+ peersPerRoom: number("CANVAS_RELAY_MAX_PEERS", DEFAULT_LIMITS.peersPerRoom),
79
+ connectionsPerIssuer: number(
80
+ "CANVAS_RELAY_MAX_CONNECTIONS",
81
+ DEFAULT_LIMITS.connectionsPerIssuer,
82
+ ),
83
+ maxMessage: number("CANVAS_RELAY_MAX_MESSAGE_MB", DEFAULT_LIMITS.maxMessage / MB, MB),
84
+ messagesPerSecond: number("CANVAS_RELAY_RATE", DEFAULT_LIMITS.messagesPerSecond),
85
+ bytesPerSecond: number("CANVAS_RELAY_BANDWIDTH_MB", DEFAULT_LIMITS.bytesPerSecond / MB, MB),
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,4 @@
1
+ // The Docker image's entry (`Dockerfile`): `canvas relay` alone, bundled into one file.
2
+ import { runRelay } from "./relay-cli";
3
+
4
+ runRelay(process.argv.slice(2), process.env);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Who may use a `canvas relay` (ADR 0008). The relay's operator gives each
3
+ * host (or team) an issuer key; `canvas serve` signs tokens with it, one for
4
+ * the host and one for guest links, each good for one relay room
5
+ * (`shared/relay-room.ts`) and 30 days. The relay checks them statelessly: the
6
+ * issuer is still configured, the HMAC matches, the room is the one joined,
7
+ * the token hasn't expired. Removing an issuer revokes all it signed.
8
+ *
9
+ * v1.<issuer>.<room>.<h|g>.<expires, unix seconds>.<HMAC-SHA256, base64url>
10
+ *
11
+ * Only `canvas serve` and the relay handle tokens; peers pass them on.
12
+ */
13
+
14
+ import { createHmac, timingSafeEqual } from "node:crypto";
15
+
16
+ export type RelayRole = "host" | "guest";
17
+
18
+ export interface RelayToken {
19
+ readonly issuer: string;
20
+ readonly room: string;
21
+ readonly role: RelayRole;
22
+ /** Unix seconds. */
23
+ readonly expires: number;
24
+ }
25
+
26
+ export interface Issuer {
27
+ readonly name: string;
28
+ readonly secret: string;
29
+ }
30
+
31
+ export const TOKEN_DAYS = 30;
32
+ const VERSION = "v1";
33
+ const ROLE = { host: "h", guest: "g" } as const;
34
+
35
+ const hmac = (secret: string, text: string) =>
36
+ createHmac("sha256", secret).update(text).digest("base64url");
37
+
38
+ export function signRelayToken(
39
+ issuer: Issuer,
40
+ room: string,
41
+ role: RelayRole,
42
+ now = Date.now(),
43
+ ): string {
44
+ const expires = Math.floor(now / 1000) + TOKEN_DAYS * 86_400;
45
+ const body = [VERSION, issuer.name, room, ROLE[role], expires].join(".");
46
+ return `${body}.${hmac(issuer.secret, body)}`;
47
+ }
48
+
49
+ export type TokenCheck =
50
+ | { readonly ok: true; readonly token: RelayToken }
51
+ | { readonly ok: false; readonly reason: string };
52
+
53
+ /** `issuers`: issuer name → secret, as the relay is configured. */
54
+ export function verifyRelayToken(
55
+ text: string,
56
+ issuers: ReadonlyMap<string, string>,
57
+ now = Date.now(),
58
+ ): TokenCheck {
59
+ const parts = text.split(".");
60
+ if (parts.length !== 6 || parts[0] !== VERSION) return { ok: false, reason: "malformed token" };
61
+ const [, issuer, room, role, expires, signature] = parts as [string, ...string[]];
62
+ const secret = issuers.get(issuer!);
63
+ if (secret === undefined) return { ok: false, reason: `unknown issuer ${issuer}` };
64
+ const expected = Buffer.from(hmac(secret, parts.slice(0, 5).join(".")));
65
+ const given = Buffer.from(signature!);
66
+ if (expected.length !== given.length || !timingSafeEqual(expected, given))
67
+ return { ok: false, reason: "bad signature" };
68
+ if (role !== "h" && role !== "g") return { ok: false, reason: "bad role" };
69
+ const seconds = Number(expires);
70
+ if (!Number.isFinite(seconds) || seconds * 1000 < now) return { ok: false, reason: "expired" };
71
+ return {
72
+ ok: true,
73
+ token: {
74
+ issuer: issuer!,
75
+ room: room!,
76
+ role: role === "h" ? "host" : "guest",
77
+ expires: seconds,
78
+ },
79
+ };
80
+ }
81
+
82
+ /** `name:secret,name:secret`: how issuer keys are configured (`CANVAS_RELAY_KEYS`). */
83
+ export function parseIssuers(text: string): Map<string, string> {
84
+ const issuers = new Map<string, string>();
85
+ for (const entry of text
86
+ .split(",")
87
+ .map((s) => s.trim())
88
+ .filter(Boolean)) {
89
+ const colon = entry.indexOf(":");
90
+ if (colon <= 0 || colon === entry.length - 1)
91
+ throw new Error(`issuer key "${entry}" is not name:secret`);
92
+ const name = entry.slice(0, colon);
93
+ if (!/^[\w-]+$/.test(name))
94
+ throw new Error(`issuer name "${name}" may only use letters, digits, _ and -`);
95
+ issuers.set(name, entry.slice(colon + 1));
96
+ }
97
+ return issuers;
98
+ }
99
+
100
+ /** One `name:secret` (`CANVAS_RELAY_KEY`): the issuer key a host signs with. */
101
+ export function parseIssuer(text: string): Issuer {
102
+ const issuers = [...parseIssuers(text)];
103
+ if (issuers.length !== 1) throw new Error("the relay key is one name:secret");
104
+ const [[name, secret]] = issuers as [[string, string]];
105
+ return { name, secret };
106
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * `canvas relay` (ADR 0008): for networks where peers can't reach the public
3
+ * Nostr relays, or each other over WebRTC. It stores nothing and reads
4
+ * nothing; it forwards what peers send, to peers holding a token signed with
5
+ * one of its issuer keys (`relay-token.ts`).
6
+ *
7
+ * /transport everything, end-to-end encrypted by the peers
8
+ * (`shared/relay-protocol.ts`); the token comes in the join
9
+ * /signal trystero's ws-relay protocol: peers find each other here,
10
+ * then connect over WebRTC. `?t=<token>`, as the trystero
11
+ * client can't send one otherwise
12
+ * /health counts, for monitoring: no rooms, no issuers
13
+ *
14
+ * One relay serves any number of boards and hosts: rooms appear on first
15
+ * join and go with the last peer. Limits (`relay-config.ts`) keep one board or
16
+ * issuer from starving the rest.
17
+ */
18
+
19
+ import type { Server, ServerWebSocket } from "bun";
20
+
21
+ import {
22
+ decodeFrame,
23
+ encodeFrame,
24
+ RELAY_HOST_REPLACED,
25
+ RELAY_RATE_LIMITED,
26
+ RELAY_REFUSED,
27
+ type RelayClientControl,
28
+ type RelayServerControl,
29
+ } from "../shared/relay-protocol";
30
+ import { DEFAULT_LIMITS, type RelayLimits } from "./relay-config";
31
+ import { verifyRelayToken, type RelayRole } from "./relay-token";
32
+
33
+ export interface RelayOptions {
34
+ readonly port: number;
35
+ readonly hostname: string;
36
+ /** Issuer name → secret. */
37
+ readonly issuers: ReadonlyMap<string, string>;
38
+ readonly tls?: { readonly cert: string; readonly key: string };
39
+ readonly limits?: Partial<RelayLimits>;
40
+ /** How long a `/transport` connection may take to join. */
41
+ readonly joinTimeoutMs?: number;
42
+ readonly log?: (line: string) => void;
43
+ }
44
+
45
+ type Socket = ServerWebSocket<SocketData>;
46
+ interface Common {
47
+ /** Set once admitted: counts against the issuer's connections. */
48
+ issuer: string | null;
49
+ readonly budget: Budget;
50
+ }
51
+ type SocketData =
52
+ | (Common & { readonly path: "signal"; readonly claimed: string })
53
+ | (Common & {
54
+ readonly path: "transport";
55
+ room: string | null;
56
+ peer: string | null;
57
+ role: RelayRole | null;
58
+ timer: ReturnType<typeof setTimeout> | null;
59
+ });
60
+ type TransportSocket = Socket & { data: { path: "transport" } };
61
+
62
+ const PEER_ID = /^[\w-]{1,64}$/;
63
+ /** Signalling carries offers, not data: small messages only. */
64
+ const MAX_SIGNAL_MESSAGE = 64 * 1024;
65
+
66
+ export function startRelay(options: RelayOptions): Server<SocketData> {
67
+ const { issuers, log = () => {} } = options;
68
+ const limits: RelayLimits = { ...DEFAULT_LIMITS, ...options.limits };
69
+ /** room → peer id → socket */
70
+ const rooms = new Map<string, Map<string, Socket>>();
71
+ const perIssuer = new Map<string, number>();
72
+
73
+ const control = (ws: Socket, message: RelayServerControl) => ws.send(JSON.stringify(message));
74
+ const refuse = (ws: Socket, message: string) => {
75
+ control(ws, { t: "error", message });
76
+ ws.close(RELAY_REFUSED, message.slice(0, 120));
77
+ };
78
+
79
+ /** Count a connection against its issuer, if the issuer has room for it. */
80
+ function admit(ws: Socket, issuer: string): boolean {
81
+ const open = perIssuer.get(issuer) ?? 0;
82
+ if (open >= limits.connectionsPerIssuer) return false;
83
+ perIssuer.set(issuer, open + 1);
84
+ ws.data.issuer = issuer;
85
+ return true;
86
+ }
87
+
88
+ function release(ws: Socket) {
89
+ const { issuer } = ws.data;
90
+ if (!issuer) return;
91
+ const open = (perIssuer.get(issuer) ?? 1) - 1;
92
+ if (open > 0) perIssuer.set(issuer, open);
93
+ else perIssuer.delete(issuer);
94
+ ws.data.issuer = null;
95
+ }
96
+
97
+ /** Within the connection's budget, or it's closed; the peer reconnects later. */
98
+ function spend(ws: Socket, bytes: number): boolean {
99
+ if (ws.data.budget.spend(bytes)) return true;
100
+ control(ws, { t: "error", message: "rate limited: too many messages or bytes" });
101
+ ws.close(RELAY_RATE_LIMITED, "rate limited");
102
+ return false;
103
+ }
104
+
105
+ function join(ws: TransportSocket, message: RelayClientControl) {
106
+ if (ws.data.room) return refuse(ws, "already joined");
107
+ if (typeof message.peer !== "string" || !PEER_ID.test(message.peer))
108
+ return refuse(ws, "bad peer id");
109
+ const check = verifyRelayToken(String(message.token), issuers);
110
+ if (!check.ok) return refuse(ws, `token refused: ${check.reason}`);
111
+ if (check.token.room !== message.room) return refuse(ws, "token refused: for another room");
112
+ const room = rooms.get(message.room) ?? new Map<string, Socket>();
113
+ if (room.has(message.peer)) return refuse(ws, "peer id in use");
114
+ if (room.size >= limits.peersPerRoom) return refuse(ws, "room full");
115
+ if (!admit(ws, check.token.issuer))
116
+ return refuse(ws, `issuer ${check.token.issuer} is at its connection limit`);
117
+ // One host per room: a newer host tab takes over, as with `canvas serve`.
118
+ if (check.token.role === "host")
119
+ for (const other of room.values())
120
+ if (other.data.path === "transport" && other.data.role === "host")
121
+ other.close(RELAY_HOST_REPLACED, "another host tab joined");
122
+
123
+ if (ws.data.timer) clearTimeout(ws.data.timer);
124
+ rooms.set(message.room, room);
125
+ const peers = [...room.keys()];
126
+ room.set(message.peer, ws);
127
+ Object.assign(ws.data, { room: message.room, peer: message.peer, role: check.token.role });
128
+ control(ws, { t: "joined", peers });
129
+ for (const other of room.values())
130
+ if (other !== ws) control(other, { t: "peer-join", peer: message.peer });
131
+ log(
132
+ `join ${check.token.issuer}/${check.token.role} (${room.size} in room, ${rooms.size} rooms)`,
133
+ );
134
+ }
135
+
136
+ function leave(ws: TransportSocket) {
137
+ if (ws.data.timer) clearTimeout(ws.data.timer);
138
+ const { room: name, peer } = ws.data;
139
+ if (!name || !peer) return;
140
+ const room = rooms.get(name);
141
+ if (room?.get(peer) !== ws) return;
142
+ room.delete(peer);
143
+ if (room.size === 0) rooms.delete(name);
144
+ else for (const other of room.values()) control(other, { t: "peer-leave", peer });
145
+ }
146
+
147
+ function forward(ws: TransportSocket, frame: Uint8Array) {
148
+ const room = ws.data.room ? rooms.get(ws.data.room) : undefined;
149
+ if (!room || !ws.data.peer) return;
150
+ const decoded = decodeFrame<{ to?: unknown }>(frame);
151
+ if (!decoded) return;
152
+ const out = encodeFrame({ from: ws.data.peer }, decoded.payload);
153
+ const to = decoded.header.to;
154
+ const targets = Array.isArray(to)
155
+ ? to.flatMap((id) => {
156
+ const target = room.get(String(id));
157
+ return target && target !== ws ? [target] : [];
158
+ })
159
+ : [...room.values()].filter((s) => s !== ws);
160
+ for (const target of targets) target.send(out);
161
+ }
162
+
163
+ /** trystero's ws-relay protocol, on Bun's pub/sub (finding 15). */
164
+ function signal(ws: Socket, raw: string | Buffer) {
165
+ if (raw.length > MAX_SIGNAL_MESSAGE) return;
166
+ let message: { type?: string; topic?: unknown; payload?: unknown };
167
+ try {
168
+ message = JSON.parse(String(raw)) as typeof message;
169
+ } catch {
170
+ return;
171
+ }
172
+ if (typeof message.topic !== "string") return;
173
+ const topic = `signal:${message.topic}`;
174
+ if (message.type === "subscribe") ws.subscribe(topic);
175
+ else if (message.type === "unsubscribe") ws.unsubscribe(topic);
176
+ else if (message.type === "publish")
177
+ ws.publish(topic, JSON.stringify({ topic: message.topic, payload: message.payload }));
178
+ }
179
+
180
+ const budget = () => new Budget(limits.messagesPerSecond, limits.bytesPerSecond);
181
+
182
+ return Bun.serve<SocketData>({
183
+ port: options.port,
184
+ hostname: options.hostname,
185
+ ...(options.tls && {
186
+ tls: { cert: Bun.file(options.tls.cert), key: Bun.file(options.tls.key) },
187
+ }),
188
+ fetch(req, server) {
189
+ const url = new URL(req.url);
190
+ if (url.pathname === "/transport") {
191
+ const data: SocketData = {
192
+ path: "transport",
193
+ issuer: null,
194
+ budget: budget(),
195
+ room: null,
196
+ peer: null,
197
+ role: null,
198
+ timer: null,
199
+ };
200
+ if (server.upgrade(req, { data })) return;
201
+ return new Response("a WebSocket endpoint\n", { status: 426 });
202
+ }
203
+ if (url.pathname === "/signal") {
204
+ const check = verifyRelayToken(url.searchParams.get("t") ?? "", issuers);
205
+ if (!check.ok) return new Response(`token refused: ${check.reason}\n`, { status: 401 });
206
+ const data: SocketData = {
207
+ path: "signal",
208
+ claimed: check.token.issuer,
209
+ issuer: null,
210
+ budget: budget(),
211
+ };
212
+ if (server.upgrade(req, { data })) return;
213
+ return new Response("a WebSocket endpoint\n", { status: 426 });
214
+ }
215
+ if (url.pathname === "/health") {
216
+ let peers = 0;
217
+ for (const room of rooms.values()) peers += room.size;
218
+ const connections = [...perIssuer.values()].reduce((sum, n) => sum + n, 0);
219
+ return Response.json({ ok: true, rooms: rooms.size, peers, connections });
220
+ }
221
+ if (url.pathname === "/") return new Response("canvas relay\n");
222
+ return new Response("not found\n", { status: 404 });
223
+ },
224
+ websocket: {
225
+ maxPayloadLength: limits.maxMessage,
226
+ open(ws) {
227
+ if (ws.data.path === "signal") {
228
+ if (!admit(ws, ws.data.claimed)) refuse(ws, "issuer at its connection limit");
229
+ return;
230
+ }
231
+ // A `/transport` connection counts once its join is admitted; until
232
+ // then it only gets a few seconds.
233
+ const transport = ws as TransportSocket;
234
+ transport.data.timer = setTimeout(() => {
235
+ if (!transport.data.room) refuse(transport, "no join in time");
236
+ }, options.joinTimeoutMs ?? 10_000);
237
+ },
238
+ message(ws, raw) {
239
+ if (!spend(ws, raw.length)) return;
240
+ if (ws.data.path === "signal") return signal(ws, raw);
241
+ const transport = ws as TransportSocket;
242
+ if (typeof raw !== "string") return forward(transport, new Uint8Array(raw));
243
+ let message: RelayClientControl;
244
+ try {
245
+ message = JSON.parse(raw) as RelayClientControl;
246
+ } catch {
247
+ return refuse(ws, "bad message");
248
+ }
249
+ if (message.t === "join") join(transport, message);
250
+ },
251
+ close(ws) {
252
+ if (ws.data.path === "transport") leave(ws as TransportSocket);
253
+ release(ws);
254
+ },
255
+ },
256
+ });
257
+ }
258
+
259
+ /** Two token buckets: messages and bytes per second, bursts up to four seconds' worth. */
260
+ export class Budget {
261
+ private messages: number;
262
+ private bytes: number;
263
+ /** When it was last spent from; refilling starts at the first spend. */
264
+ private last: number | null = null;
265
+
266
+ constructor(
267
+ private readonly messagesPerSecond: number,
268
+ private readonly bytesPerSecond: number,
269
+ private readonly burstSeconds = 4,
270
+ ) {
271
+ this.messages = messagesPerSecond * burstSeconds;
272
+ this.bytes = bytesPerSecond * burstSeconds;
273
+ }
274
+
275
+ spend(bytes: number, now = performance.now()): boolean {
276
+ const seconds = this.last === null ? 0 : Math.max(0, now - this.last) / 1000;
277
+ this.last = now;
278
+ this.messages = Math.min(
279
+ this.messagesPerSecond * this.burstSeconds,
280
+ this.messages + seconds * this.messagesPerSecond,
281
+ );
282
+ this.bytes = Math.min(
283
+ this.bytesPerSecond * this.burstSeconds,
284
+ this.bytes + seconds * this.bytesPerSecond,
285
+ );
286
+ // One message may be larger than the burst (a whole thread): let it through
287
+ // on a full bucket, and go into debt for it.
288
+ if (
289
+ this.messages < 1 ||
290
+ (this.bytes < bytes && this.bytes < this.bytesPerSecond * this.burstSeconds)
291
+ )
292
+ return false;
293
+ this.messages -= 1;
294
+ this.bytes -= bytes;
295
+ return true;
296
+ }
297
+ }
@@ -8,13 +8,21 @@
8
8
  import type { ServerWebSocket } from "bun";
9
9
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { HOST_REPLACED, type ClientToServer, type ServerToClient } from "../shared/protocol";
11
+ import {
12
+ HOST_REPLACED,
13
+ type ClientToServer,
14
+ type RelayVia,
15
+ type ServerToClient,
16
+ type WelcomeRelay,
17
+ } from "../shared/protocol";
18
+ import { relayRoom } from "../shared/relay-room";
12
19
  import { AgentManager, detectAgents } from "./agents";
13
20
  import { BoardMcp } from "./board-mcp";
14
21
  import { Files } from "./files";
15
22
  import { Scratch } from "./scratch";
16
23
  import { canvasSkills } from "./skills";
17
24
  import { Store } from "./store";
25
+ import { signRelayToken, type Issuer } from "./relay-token";
18
26
  import { Terminals } from "./terminals";
19
27
 
20
28
  export interface ServeOptions {
@@ -24,12 +32,33 @@ export interface ServeOptions {
24
32
  readonly tls?: { readonly cert: string; readonly key: string };
25
33
  /** The published version this runs as; none for a checkout. */
26
34
  readonly version?: string;
35
+ /** Guests reach the board through this `canvas relay` (ADR 0008). */
36
+ readonly relay?: ServeRelay;
37
+ }
38
+
39
+ export interface ServeRelay {
40
+ readonly url: string;
41
+ readonly via: RelayVia;
42
+ /** The issuer key the relay's operator gave this host. */
43
+ readonly issuer: Issuer;
27
44
  }
28
45
 
29
46
  export async function serve(options: ServeOptions) {
30
47
  const store = new Store(options.dir);
31
48
  excludeFromGit(options.dir);
32
49
  const room = await store.room();
50
+ const relayRoomName = options.relay ? await relayRoom(room.key) : null;
51
+ // Signed for every host tab that connects: its token and the one its guest
52
+ // links carry are good for 30 days from then.
53
+ const relaySetup = (): WelcomeRelay | null =>
54
+ options.relay && relayRoomName
55
+ ? {
56
+ url: options.relay.url,
57
+ via: options.relay.via,
58
+ hostToken: signRelayToken(options.relay.issuer, relayRoomName, "host"),
59
+ guestToken: signRelayToken(options.relay.issuer, relayRoomName, "guest"),
60
+ }
61
+ : null;
33
62
  // The host tab: the board, and so every board tool call, lives there.
34
63
  let host: ServerWebSocket<unknown> | null = null;
35
64
  const broadcast = (message: ServerToClient) => host?.send(JSON.stringify(message));
@@ -166,6 +195,7 @@ export async function serve(options: ServeOptions) {
166
195
  agents: agentDefinitions.map(({ kind, label }) => ({ kind, label })),
167
196
  board: board ? Buffer.from(board).toString("base64") : null,
168
197
  sessions: agents.snapshots(),
198
+ relay: relaySetup(),
169
199
  } satisfies ServerToClient),
170
200
  );
171
201
  },
@@ -157,6 +157,19 @@ export interface RoomSecrets {
157
157
  readonly hostPrivateKey: JsonWebKey;
158
158
  }
159
159
 
160
+ /** How a board uses its `canvas relay` (ADR 0008): for everything, or only to meet. */
161
+ export type RelayVia = "transport" | "signal";
162
+
163
+ /** What the host's browser needs for the board's relay, signed fresh for each host tab. */
164
+ export interface WelcomeRelay {
165
+ readonly url: string;
166
+ readonly via: RelayVia;
167
+ /** The host tab's own token. */
168
+ readonly hostToken: string;
169
+ /** The token guest links carry. */
170
+ readonly guestToken: string;
171
+ }
172
+
160
173
  /**
161
174
  * Close code of a host browser's WebSocket when another one connected: one
162
175
  * host tab at a time, and the replaced one must not reconnect by itself, or
@@ -219,6 +232,8 @@ export type ServerToClient =
219
232
  /** base64 Yjs update of the persisted board, if any. */
220
233
  readonly board: string | null;
221
234
  readonly sessions: ReadonlyArray<SessionSnapshot>;
235
+ /** The board's `canvas relay`, if it uses one (ADR 0008). */
236
+ readonly relay?: WelcomeRelay | null;
222
237
  }
223
238
  | { readonly t: "agent-meta"; readonly meta: SessionMeta }
224
239
  | { readonly t: "agent-event"; readonly sessionId: string; readonly event: AgentEvent }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The wire of `canvas relay`'s `/transport` (ADR 0008). The relay forwards
3
+ * opaque bytes between the peers of a room and knows nothing else: payloads
4
+ * are end-to-end encrypted by the peers (`web/lib/transport/relay.ts`).
5
+ *
6
+ * Control messages are JSON text frames. Data is a binary frame:
7
+ *
8
+ * [u32 header length][header, JSON][payload]
9
+ *
10
+ * peer → relay header: `{to?: string[]}` (absent: everyone else in the room)
11
+ * relay → peer header: `{from: string}`, stamped by the relay — a peer can't
12
+ * send as another.
13
+ */
14
+
15
+ export type RelayClientControl = {
16
+ readonly t: "join";
17
+ readonly room: string;
18
+ /** Chosen by the peer; the relay binds it to this socket for as long as it's open. */
19
+ readonly peer: string;
20
+ readonly token: string;
21
+ };
22
+
23
+ export type RelayServerControl =
24
+ | { readonly t: "joined"; readonly peers: ReadonlyArray<string> }
25
+ | { readonly t: "peer-join"; readonly peer: string }
26
+ | { readonly t: "peer-leave"; readonly peer: string }
27
+ | { readonly t: "error"; readonly message: string };
28
+
29
+ /** Close codes. The peer doesn't reconnect after these, as retrying can't help. */
30
+ export const RELAY_REFUSED = 4401;
31
+ /** A newer host tab joined the room. */
32
+ export const RELAY_HOST_REPLACED = 4409;
33
+ /** Over the connection's message or byte rate; the peer reconnects and joins again. */
34
+ export const RELAY_RATE_LIMITED = 4429;
35
+
36
+ const encoder = new TextEncoder();
37
+ const decoder = new TextDecoder();
38
+
39
+ export function encodeFrame(header: object, payload: Uint8Array): Uint8Array<ArrayBuffer> {
40
+ const head = encoder.encode(JSON.stringify(header));
41
+ const frame = new Uint8Array(4 + head.length + payload.length);
42
+ new DataView(frame.buffer).setUint32(0, head.length);
43
+ frame.set(head, 4);
44
+ frame.set(payload, 4 + head.length);
45
+ return frame;
46
+ }
47
+
48
+ export function decodeFrame<H>(frame: Uint8Array): { header: H; payload: Uint8Array } | null {
49
+ if (frame.length < 4) return null;
50
+ const length = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(0);
51
+ if (4 + length > frame.length) return null;
52
+ try {
53
+ const header = JSON.parse(decoder.decode(frame.subarray(4, 4 + length))) as H;
54
+ return { header, payload: frame.subarray(4 + length) };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What peers of a board derive from its key for a `canvas relay` (ADR 0008):
3
+ * the relay room, and the key that seals messages over the relay transport.
4
+ *
5
+ * The relay room comes from the board key `k`, not its `?room=` id: the web
6
+ * host sees that id, and the relay shouldn't be able to match its rooms to
7
+ * board URLs — nor anyone guess a room without the key.
8
+ *
9
+ * WebCrypto only: `canvas serve` and the browser both use it.
10
+ */
11
+
12
+ const encoder = new TextEncoder();
13
+
14
+ export function b64url(bytes: Uint8Array): string {
15
+ let binary = "";
16
+ for (const byte of bytes) binary += String.fromCharCode(byte);
17
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
+ }
19
+
20
+ /** Key material from a board key: HKDF-SHA256 with a purpose label. */
21
+ export async function deriveBits(
22
+ boardKey: string,
23
+ info: string,
24
+ bits = 256,
25
+ ): Promise<Uint8Array<ArrayBuffer>> {
26
+ const base = await crypto.subtle.importKey("raw", encoder.encode(boardKey), "HKDF", false, [
27
+ "deriveBits",
28
+ ]);
29
+ const derived = await crypto.subtle.deriveBits(
30
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: encoder.encode(info) },
31
+ base,
32
+ bits,
33
+ );
34
+ return new Uint8Array(derived);
35
+ }
36
+
37
+ /** The relay room of a board: the same for everyone holding its key, meaningless without it. */
38
+ export async function relayRoom(boardKey: string): Promise<string> {
39
+ return b64url(await deriveBits(boardKey, "canvas-relay-room", 128));
40
+ }