@a-t-h-i/bot-lobby 0.6.9 → 0.6.11

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/README.md CHANGED
@@ -388,6 +388,13 @@ deletion), and one call draws at most 100 shapes and removes at most 50.
388
388
  - Rooms are Excalidraw's own collaboration protocol, end-to-end encrypted with the
389
389
  key in the link, over `oss-collab.excalidraw.com`. A self-hosted collaboration
390
390
  server is used when `BOT_LOBBY_EXCALIDRAW_SERVER` names it.
391
+ - **When `t` says it could not reach the server**, the reason in brackets says
392
+ why: a name that did not resolve (the network or DNS), a refused or timed-out
393
+ connection, a certificate a firewall replaced (point `NODE_EXTRA_CA_CERTS` at
394
+ its certificate), or a refusal with an HTTP status. Where only a proxy lets
395
+ traffic out, set `HTTPS_PROXY` (`NO_PROXY` exempts hosts); agents then join
396
+ through it, and the message names it. A network that blocks websockets but not
397
+ HTTPS still works: the seat falls back to long-polling, as a browser does.
391
398
 
392
399
  Common keys: `tab` switches tabs, `esc` browses (arrows, single-key
393
400
  commands), `ctrl+f` searches, `ctrl+s` saves the plan, `alt+o` browses
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -56,6 +56,7 @@
56
56
  "typescript": "^5.7.0"
57
57
  },
58
58
  "dependencies": {
59
+ "https-proxy-agent": "^9.1.0",
59
60
  "socket.io-client": "4.8.4"
60
61
  }
61
62
  }
@@ -11,8 +11,9 @@
11
11
  * itself. And it does not draw while nobody else is in the room, because
12
12
  * nothing keeps a scene for a room but the browsers in it.
13
13
  */
14
+ import { HttpsProxyAgent } from "https-proxy-agent";
14
15
  import { io, type Socket } from "socket.io-client";
15
- import { DEFAULT_SERVER, seal, unseal, type RoomLink } from "./room.ts";
16
+ import { DEFAULT_ORIGIN, DEFAULT_SERVER, seal, unseal, type RoomLink } from "./room.ts";
16
17
  import { describeScene, mergeElements, plainText, planDraw, sceneBounds, visible, type DrawOutcome, type DrawRequest, type Scene, type SceneElement } from "./scene.ts";
17
18
 
18
19
  /** The part of a socket.io client a seat uses; tests and other transports can stand in for it. */
@@ -51,7 +52,72 @@ const SCENE_WAIT_MS = 5000;
51
52
  const MAX_MESSAGE_CHARS = 900_000;
52
53
 
53
54
  function defaultConnect(server: string): SocketLike {
54
- return io(server, { transports: ["websocket", "polling"], autoUnref: true, timeout: 8000 });
55
+ const proxy = proxyFor(server);
56
+ // Where a network lets plain HTTPS through but not a websocket, the seat falls back to long-polling, as a browser does.
57
+ // socket.io types `agent` for browsers (string | boolean); in Node it is passed on to the http and ws requests as an http.Agent.
58
+ const agent = proxy ? { agent: new HttpsProxyAgent(proxy) as unknown as string } : {};
59
+ // Excalidraw's collaboration server refuses handshakes whose Origin is not on its allowlist, so a seat presents the site a browser would come from.
60
+ let site = DEFAULT_ORIGIN;
61
+ if (server !== DEFAULT_SERVER) {
62
+ try {
63
+ site = new URL(server).origin;
64
+ } catch {
65
+ /* fall back to DEFAULT_ORIGIN */
66
+ }
67
+ }
68
+ return io(server, { transports: ["websocket", "polling"], tryAllTransports: true, autoUnref: true, timeout: 8000, extraHeaders: { Origin: site }, ...agent });
69
+ }
70
+
71
+ /**
72
+ * The proxy the environment names for this server (`HTTPS_PROXY`, `HTTP_PROXY`,
73
+ * `ALL_PROXY`, in either case), unless `NO_PROXY` exempts its host. Node's own
74
+ * sockets ignore these variables, so without this a seat on a network that only
75
+ * lets traffic out through a proxy never reaches the server.
76
+ */
77
+ export function proxyFor(server: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
78
+ let url: URL;
79
+ try {
80
+ url = new URL(server);
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ const pick = (...names: string[]) => names.map((name) => env[name]?.trim()).find((value) => value);
85
+ const proxy = url.protocol === "http:" ? pick("http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY") : pick("https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY");
86
+ if (!proxy) return undefined;
87
+ const host = url.hostname.toLowerCase();
88
+ const exempt = (pick("no_proxy", "NO_PROXY") ?? "").split(/[\s,]+/).filter(Boolean);
89
+ for (const entry of exempt) {
90
+ if (entry === "*") return undefined;
91
+ const name = entry.toLowerCase().replace(/:\d+$/, "").replace(/^\*?\./, "");
92
+ if (host === name || host.endsWith(`.${name}`)) return undefined;
93
+ }
94
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(proxy) ? proxy : `http://${proxy}`;
95
+ }
96
+
97
+ /**
98
+ * Why a connection failed, in words a person can act on. socket.io reports
99
+ * every transport failure as "websocket error" or "xhr poll error" and keeps
100
+ * the cause (a refused upgrade, an unknown host, a certificate) underneath.
101
+ */
102
+ export function connectFailure(error: unknown): string {
103
+ const outer = error as { message?: string; description?: unknown; context?: { responseText?: unknown } } | undefined;
104
+ const description = outer?.description as { message?: unknown } | number | string | undefined;
105
+ // A websocket keeps its cause in `description`; long-polling keeps the HTTP status there (0 when no response came) and the cause in the request's text.
106
+ const polled = typeof outer?.context?.responseText === "string" ? outer.context.responseText.split("\n")[0]!.replace(/^Error: /, "") : "";
107
+ const text =
108
+ typeof description === "object" && typeof description?.message === "string" && description.message
109
+ ? description.message
110
+ : typeof description === "number" && description > 0
111
+ ? `Unexpected server response: ${description}`
112
+ : typeof description === "string" && description
113
+ ? description
114
+ : polled || outer?.message || "";
115
+ const status = /Unexpected server response: (\d{3})/.exec(text)?.[1];
116
+ if (status) return `the connection was refused with HTTP ${status}`;
117
+ if (/ENOTFOUND|EAI_AGAIN/.test(text)) return `${text}: the server's name did not resolve, so check the network or DNS`;
118
+ if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/.test(text)) return `${text}: check the network, or set HTTPS_PROXY if it only lets traffic out through a proxy`;
119
+ if (/certificate|CERT_|SSL|TLS/i.test(text)) return `${text}: a proxy or firewall may be intercepting HTTPS; NODE_EXTRA_CA_CERTS can point at its certificate`;
120
+ return text;
55
121
  }
56
122
 
57
123
  export function collaborationServer(env: NodeJS.ProcessEnv = process.env): string {
@@ -122,11 +188,13 @@ export class ExcalidrawRoom {
122
188
  done = true;
123
189
  this.opening = undefined;
124
190
  this.close();
125
- reject(new Error(`could not reach the Excalidraw collaboration server ${server}${lastError ? ` (${lastError})` : ""}`));
191
+ // Only the proxy's host is named: its URL may carry a password.
192
+ const via = this.options.connect ? undefined : proxyFor(server);
193
+ reject(new Error(`could not reach the Excalidraw collaboration server ${server}${via ? ` through the proxy ${new URL(via).host}` : ""}${lastError ? ` (${lastError})` : ""}`));
126
194
  }, this.options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS),
127
195
  );
128
196
  socket.on("connect_error", (error: Error) => {
129
- lastError = error.message;
197
+ lastError = connectFailure(error) || lastError;
130
198
  });
131
199
  // Every (re)connection is greeted with init-room; the seat answers by joining.
132
200
  socket.on("init-room", () => {