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

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.5",
4
4
  "description": "A multiplayer canvas for coding agents that run on your machine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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";
@@ -26,11 +27,9 @@ export async function serve(options: ServeOptions) {
26
27
  const store = new Store(options.dir);
27
28
  excludeFromGit(options.dir);
28
29
  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
- };
30
+ // The host tab: the board, and so every board tool call, lives there.
31
+ let host: ServerWebSocket<unknown> | null = null;
32
+ const broadcast = (message: ServerToClient) => host?.send(JSON.stringify(message));
34
33
 
35
34
  // Scratch files are written by agents' board tools; `files` mirrors them.
36
35
  const scratch = new Scratch(options.dir, (path) => files.scratchChanged(path));
@@ -46,9 +45,8 @@ export async function serve(options: ServeOptions) {
46
45
  read: (path) => files.read(path),
47
46
  scratch,
48
47
  relay: (call) => {
49
- const [ws] = clients;
50
- if (!ws) return false;
51
- ws.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
48
+ if (!host) return false;
49
+ host.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
52
50
  return true;
53
51
  },
54
52
  });
@@ -148,7 +146,11 @@ export async function serve(options: ServeOptions) {
148
146
  websocket: {
149
147
  maxPayloadLength: 64 * 1024 * 1024,
150
148
  open(ws) {
151
- clients.add(ws);
149
+ if (host) {
150
+ files.drop(host);
151
+ host.close(HOST_REPLACED, "opened in another tab");
152
+ }
153
+ host = ws;
152
154
  const board = store.board();
153
155
  const { token: _, ...secrets } = room;
154
156
  ws.send(
@@ -163,6 +165,8 @@ export async function serve(options: ServeOptions) {
163
165
  );
164
166
  },
165
167
  message(ws, data) {
168
+ // Still in flight from a tab that was just replaced.
169
+ if (ws !== host) return;
166
170
  try {
167
171
  handle(ws, JSON.parse(String(data)) as ClientToServer);
168
172
  } catch (error) {
@@ -170,7 +174,7 @@ export async function serve(options: ServeOptions) {
170
174
  }
171
175
  },
172
176
  close(ws) {
173
- clients.delete(ws);
177
+ if (ws === host) host = null;
174
178
  files.drop(ws);
175
179
  },
176
180
  },
@@ -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 }