@frebreco/canvas 0.3.0-next.4 → 0.3.0-next.6

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-next.4",
3
+ "version": "0.3.0-next.6",
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
@@ -54,6 +54,7 @@ const { server, room } = await serve({
54
54
  port: Number(values.port),
55
55
  hostname: tlsHost ? "0.0.0.0" : "127.0.0.1",
56
56
  ...(tlsHost && { tls: { cert: values.cert, key: values.key } }),
57
+ ...(manifest.version && { version: manifest.version }),
57
58
  });
58
59
 
59
60
  const serverUrl = tlsHost ? `wss://${tlsHost}:${server.port}` : `ws://127.0.0.1:${server.port}`;
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * `canvas serve`: the local half of canvas. One WebSocket endpoint that only
3
- * the host's browser may use (it presents the token from the printed link).
3
+ * the host's browser may use (it presents the token from the printed link),
4
+ * in one tab at a time: a newer tab takes over from an older one.
4
5
  * Nothing here knows about guests — the host's browser is the relay.
5
6
  */
6
7
 
7
8
  import type { ServerWebSocket } from "bun";
8
9
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
9
10
  import { join } from "node:path";
10
- import type { ClientToServer, ServerToClient } from "../shared/protocol";
11
+ import { HOST_REPLACED, type ClientToServer, type ServerToClient } from "../shared/protocol";
11
12
  import { AgentManager, detectAgents } from "./agents";
12
13
  import { BoardMcp } from "./board-mcp";
13
14
  import { Files } from "./files";
@@ -20,17 +21,17 @@ export interface ServeOptions {
20
21
  readonly port: number;
21
22
  readonly hostname: string;
22
23
  readonly tls?: { readonly cert: string; readonly key: string };
24
+ /** The published version this runs as; none for a checkout. */
25
+ readonly version?: string;
23
26
  }
24
27
 
25
28
  export async function serve(options: ServeOptions) {
26
29
  const store = new Store(options.dir);
27
30
  excludeFromGit(options.dir);
28
31
  const room = await store.room();
29
- const clients = new Set<ServerWebSocket<unknown>>();
30
- const broadcast = (message: ServerToClient) => {
31
- const text = JSON.stringify(message);
32
- for (const ws of clients) ws.send(text);
33
- };
32
+ // The host tab: the board, and so every board tool call, lives there.
33
+ let host: ServerWebSocket<unknown> | null = null;
34
+ const broadcast = (message: ServerToClient) => host?.send(JSON.stringify(message));
34
35
 
35
36
  // Scratch files are written by agents' board tools; `files` mirrors them.
36
37
  const scratch = new Scratch(options.dir, (path) => files.scratchChanged(path));
@@ -46,9 +47,8 @@ export async function serve(options: ServeOptions) {
46
47
  read: (path) => files.read(path),
47
48
  scratch,
48
49
  relay: (call) => {
49
- const [ws] = clients;
50
- if (!ws) return false;
51
- ws.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
50
+ if (!host) return false;
51
+ host.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
52
52
  return true;
53
53
  },
54
54
  });
@@ -148,12 +148,17 @@ export async function serve(options: ServeOptions) {
148
148
  websocket: {
149
149
  maxPayloadLength: 64 * 1024 * 1024,
150
150
  open(ws) {
151
- clients.add(ws);
151
+ if (host) {
152
+ files.drop(host);
153
+ host.close(HOST_REPLACED, "opened in another tab");
154
+ }
155
+ host = ws;
152
156
  const board = store.board();
153
157
  const { token: _, ...secrets } = room;
154
158
  ws.send(
155
159
  JSON.stringify({
156
160
  t: "welcome",
161
+ version: options.version ?? null,
157
162
  room: secrets,
158
163
  cwd: options.dir,
159
164
  agents: agentDefinitions.map(({ kind, label }) => ({ kind, label })),
@@ -163,6 +168,8 @@ export async function serve(options: ServeOptions) {
163
168
  );
164
169
  },
165
170
  message(ws, data) {
171
+ // Still in flight from a tab that was just replaced.
172
+ if (ws !== host) return;
166
173
  try {
167
174
  handle(ws, JSON.parse(String(data)) as ClientToServer);
168
175
  } catch (error) {
@@ -170,7 +177,7 @@ export async function serve(options: ServeOptions) {
170
177
  }
171
178
  },
172
179
  close(ws) {
173
- clients.delete(ws);
180
+ if (ws === host) host = null;
174
181
  files.drop(ws);
175
182
  },
176
183
  },
@@ -157,6 +157,13 @@ export interface RoomSecrets {
157
157
  readonly hostPrivateKey: JsonWebKey;
158
158
  }
159
159
 
160
+ /**
161
+ * Close code of a host browser's WebSocket when another one connected: one
162
+ * host tab at a time, and the replaced one must not reconnect by itself, or
163
+ * two tabs would take the connection from each other forever.
164
+ */
165
+ export const HOST_REPLACED = 4001;
166
+
160
167
  export type ClientToServer =
161
168
  | { readonly t: "board-save"; readonly state: string }
162
169
  | { readonly t: "agent-create"; readonly id: string; readonly agent: AgentKind }
@@ -204,6 +211,8 @@ export type ClientToServer =
204
211
  export type ServerToClient =
205
212
  | {
206
213
  readonly t: "welcome";
214
+ /** The version `canvas serve` runs as; null for a checkout. */
215
+ readonly version: string | null;
207
216
  readonly room: RoomSecrets;
208
217
  readonly cwd: string;
209
218
  readonly agents: ReadonlyArray<AgentInfo>;
@@ -251,6 +260,8 @@ export interface RoomState {
251
260
  readonly access: GuestAccess;
252
261
  readonly cwd: string;
253
262
  readonly agents: ReadonlyArray<AgentInfo>;
263
+ /** `canvas serve`'s version, null for a checkout; absent from hosts older than it. */
264
+ readonly version?: string | null;
254
265
  }
255
266
 
256
267
  /** Requests a guest sends the host; the host answers `{ok}` or `{ok:false, error}`. */