@xiaohhhh1/canvas-agent 0.4.20 → 0.4.21

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.
@@ -1,7 +1,20 @@
1
1
  import type { CanvasAgentConfig } from "./config.js";
2
+ export type RelayBridgeStatus = {
3
+ ready: boolean;
4
+ lastReadyAt?: string;
5
+ lastDisconnectAt?: string;
6
+ };
7
+ export type RelayBridgeOptions = {
8
+ relayUrl?: string;
9
+ reconnectDelayMs?: number;
10
+ heartbeatIntervalMs?: number;
11
+ readyTimeoutMs?: number;
12
+ livenessTimeoutMs?: number;
13
+ onStatus?: (status: RelayBridgeStatus) => void;
14
+ };
2
15
  /**
3
16
  * Keeps an outbound, encrypted connection to the production relay. The canvas
4
17
  * browser can then use same-origin requests instead of directly reaching a
5
18
  * loopback HTTP address, which Chromium clients can block before CORS runs.
6
19
  */
7
- export declare function startRelayBridge(config: CanvasAgentConfig): () => void;
20
+ export declare function startRelayBridge(config: CanvasAgentConfig, options?: RelayBridgeOptions): () => void;
@@ -9,8 +9,12 @@ const LIVENESS_TIMEOUT_MS = 45_000;
9
9
  * browser can then use same-origin requests instead of directly reaching a
10
10
  * loopback HTTP address, which Chromium clients can block before CORS runs.
11
11
  */
12
- export function startRelayBridge(config) {
13
- const relayUrl = process.env.CANVAS_AGENT_RELAY_URL || DEFAULT_RELAY_URL;
12
+ export function startRelayBridge(config, options = {}) {
13
+ const relayUrl = options.relayUrl || process.env.CANVAS_AGENT_RELAY_URL || DEFAULT_RELAY_URL;
14
+ const reconnectDelayMs = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
15
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
16
+ const readyTimeoutMs = options.readyTimeoutMs ?? READY_TIMEOUT_MS;
17
+ const livenessTimeoutMs = options.livenessTimeoutMs ?? LIVENESS_TIMEOUT_MS;
14
18
  const subscriptions = new Map();
15
19
  let socket = null;
16
20
  let stopped = false;
@@ -19,6 +23,9 @@ export function startRelayBridge(config) {
19
23
  let reconnectTimer = null;
20
24
  let heartbeatTimer = null;
21
25
  let readyTimer = null;
26
+ let lastReadyAt;
27
+ let lastDisconnectAt;
28
+ const publishStatus = () => options.onStatus?.({ ready: relayReady, lastReadyAt, lastDisconnectAt });
22
29
  const send = (message) => {
23
30
  const current = socket;
24
31
  if (!relayReady || current?.readyState !== WebSocket.OPEN)
@@ -52,7 +59,13 @@ export function startRelayBridge(config) {
52
59
  stopSubscription(clientId);
53
60
  const controller = new AbortController();
54
61
  subscriptions.set(clientId, controller);
55
- void pipeEvents(clientId, config, controller.signal, send).finally(() => {
62
+ void pipeEvents(clientId, config, controller.signal, send)
63
+ .catch(() => {
64
+ // A browser may disappear while the relay subscription is being
65
+ // established. That is a recoverable client disconnect, not a
66
+ // process-fatal unhandled rejection.
67
+ })
68
+ .finally(() => {
56
69
  if (subscriptions.get(clientId) === controller)
57
70
  subscriptions.delete(clientId);
58
71
  });
@@ -99,7 +112,7 @@ export function startRelayBridge(config) {
99
112
  reconnectTimer = setTimeout(() => {
100
113
  reconnectTimer = null;
101
114
  connect();
102
- }, RECONNECT_DELAY_MS);
115
+ }, reconnectDelayMs);
103
116
  };
104
117
  const connect = () => {
105
118
  if (stopped || socket)
@@ -107,6 +120,10 @@ export function startRelayBridge(config) {
107
120
  try {
108
121
  const current = new WebSocket(relayUrl);
109
122
  socket = current;
123
+ // Cover DNS/TCP/TLS/WebSocket handshakes as well as the relay hello.
124
+ // Starting this timer only after `open` leaves a CONNECTING socket
125
+ // able to stall forever and prevents every future reconnect.
126
+ readyTimer = setTimeout(() => current.terminate(), readyTimeoutMs);
110
127
  let disconnected = false;
111
128
  const disconnect = () => {
112
129
  if (disconnected)
@@ -115,6 +132,8 @@ export function startRelayBridge(config) {
115
132
  if (socket === current)
116
133
  socket = null;
117
134
  relayReady = false;
135
+ lastDisconnectAt = new Date().toISOString();
136
+ publishStatus();
118
137
  clearConnectionTimers();
119
138
  abortSubscriptions();
120
139
  scheduleReconnect();
@@ -127,13 +146,14 @@ export function startRelayBridge(config) {
127
146
  current.terminate();
128
147
  return;
129
148
  }
130
- readyTimer = setTimeout(() => current.terminate(), READY_TIMEOUT_MS);
131
149
  });
132
150
  current.on("message", (raw) => {
133
151
  try {
134
152
  const message = JSON.parse(raw.toString());
135
153
  if (message.type === "ready") {
136
154
  relayReady = true;
155
+ lastReadyAt = new Date().toISOString();
156
+ publishStatus();
137
157
  lastHeartbeatAck = Date.now();
138
158
  if (readyTimer)
139
159
  clearTimeout(readyTimer);
@@ -141,12 +161,12 @@ export function startRelayBridge(config) {
141
161
  if (heartbeatTimer)
142
162
  clearInterval(heartbeatTimer);
143
163
  heartbeatTimer = setInterval(() => {
144
- if (Date.now() - lastHeartbeatAck > LIVENESS_TIMEOUT_MS) {
164
+ if (Date.now() - lastHeartbeatAck > livenessTimeoutMs) {
145
165
  current.terminate();
146
166
  return;
147
167
  }
148
168
  send({ type: "heartbeat", time: Date.now() });
149
- }, HEARTBEAT_INTERVAL_MS);
169
+ }, heartbeatIntervalMs);
150
170
  heartbeatTimer.unref();
151
171
  return;
152
172
  }
@@ -168,6 +188,8 @@ export function startRelayBridge(config) {
168
188
  catch {
169
189
  socket = null;
170
190
  relayReady = false;
191
+ lastDisconnectAt = new Date().toISOString();
192
+ publishStatus();
171
193
  clearConnectionTimers();
172
194
  scheduleReconnect();
173
195
  }
@@ -33,6 +33,7 @@ export function startHttpServer() {
33
33
  };
34
34
  const workflows = new WorkflowManager(config, emit);
35
35
  const fastmoss = new FastMossIntegration();
36
+ let relayStatus = { ready: false };
36
37
  const app = express();
37
38
  app.disable("x-powered-by");
38
39
  app.use(express.json({ limit: "30mb" }));
@@ -56,7 +57,7 @@ export function startHttpServer() {
56
57
  return void res.json({});
57
58
  next();
58
59
  });
59
- app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION }));
60
+ app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION, relayReady: relayStatus.ready, relayLastReadyAt: relayStatus.lastReadyAt, relayLastDisconnectAt: relayStatus.lastDisconnectAt }));
60
61
  app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
61
62
  app.use((req, res, next) => {
62
63
  if (validToken(req, requestUrl(req, config), config.token))
@@ -285,7 +286,7 @@ export function startHttpServer() {
285
286
  console.log("Codex MCP is not installed by this command.");
286
287
  console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @xiaohhhh1/canvas-agent mcp");
287
288
  console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
288
- startRelayBridge(config);
289
+ startRelayBridge(config, { onStatus: (status) => { relayStatus = status; } });
289
290
  if (logger.enabled)
290
291
  console.log(`Debug log: ${logger.filePath}`);
291
292
  logger.info("Canvas Agent started", { url: config.url, workspace: ensureSiteWorkspace(config).workspacePath, debugLog: logger.filePath });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",