@cjhyy/code-shell-server 0.9.1 → 0.9.3

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.
@@ -31,6 +31,10 @@ export interface HeadlessServeOptions {
31
31
  */
32
32
  passcode?: string;
33
33
  log?: WorkerBridgeLog;
34
+ /** Test/host tuning; production defaults to 60 seconds. */
35
+ pendingWorkerResponseTtlMs?: number;
36
+ /** Test/host tuning; production defaults to a 5-second sweep. */
37
+ pendingWorkerResponseReaperMs?: number;
34
38
  }
35
39
  export interface HeadlessServer {
36
40
  url: string;
@@ -42,6 +46,8 @@ export interface HeadlessServer {
42
46
  bridge: WorkerBridgeCore;
43
47
  /** Live authenticated tab count (for tests/status). */
44
48
  tabCount(): number;
49
+ /** Outstanding browser→worker RPC count (for status/tests). */
50
+ pendingResponseCount(): number;
45
51
  close(): Promise<void>;
46
52
  }
47
53
  export declare function startHeadlessServer(opts: HeadlessServeOptions): Promise<HeadlessServer>;
@@ -40,6 +40,14 @@ export async function startHeadlessServer(opts) {
40
40
  throw new Error(`sessionRootDir must equal <workerDataRoot>/sessions (${expectedSessionRootDir}) so the host and worker authorize the same session store`);
41
41
  }
42
42
  const sessionManager = new SessionManager(sessionRootDir);
43
+ const pendingResponseTtlMs = opts.pendingWorkerResponseTtlMs ?? 60_000;
44
+ const pendingResponseReaperMs = opts.pendingWorkerResponseReaperMs ?? 5_000;
45
+ if (!Number.isSafeInteger(pendingResponseTtlMs) ||
46
+ pendingResponseTtlMs <= 0 ||
47
+ !Number.isSafeInteger(pendingResponseReaperMs) ||
48
+ pendingResponseReaperMs <= 0) {
49
+ throw new Error("pending worker response timeouts must be positive safe integers");
50
+ }
43
51
  const passcode = new AccessPasscode({ filePath: join(opts.dataDir, "access.json") });
44
52
  let generatedPasscode;
45
53
  if (opts.passcode) {
@@ -51,22 +59,65 @@ export async function startHeadlessServer(opts) {
51
59
  }
52
60
  const tabs = new Set();
53
61
  const pendingWorkerResponses = new Map();
62
+ const pendingResponsesByTab = new Map();
54
63
  let nextTabId = 1;
55
64
  let nextWorkerRequestId = 1;
65
+ const sendToTab = (tab, line, context) => {
66
+ if (tab.readyState !== tab.OPEN)
67
+ return false;
68
+ try {
69
+ tab.send(line);
70
+ return true;
71
+ }
72
+ catch (error) {
73
+ // readyState can change between the guard and send. A dead browser must
74
+ // never crash the shared worker router or its timeout reaper.
75
+ log("tab.send_failed", {
76
+ context,
77
+ error: error instanceof Error ? error.message : String(error),
78
+ });
79
+ return false;
80
+ }
81
+ };
56
82
  const broadcast = (line) => {
57
83
  for (const tab of tabs) {
58
- if (tab.readyState === tab.OPEN)
59
- tab.send(line);
84
+ sendToTab(tab, line, "broadcast");
85
+ }
86
+ };
87
+ const deletePendingWorkerResponse = (requestId) => {
88
+ const route = pendingWorkerResponses.get(requestId);
89
+ if (!route)
90
+ return;
91
+ pendingWorkerResponses.delete(requestId);
92
+ const remaining = (pendingResponsesByTab.get(route.tabId) ?? 1) - 1;
93
+ if (remaining > 0)
94
+ pendingResponsesByTab.set(route.tabId, remaining);
95
+ else
96
+ pendingResponsesByTab.delete(route.tabId);
97
+ };
98
+ const clearPendingWorkerResponsesForTab = (tabId) => {
99
+ for (const [requestId, route] of pendingWorkerResponses) {
100
+ if (route.tabId === tabId)
101
+ deletePendingWorkerResponse(requestId);
60
102
  }
61
103
  };
62
104
  const failPendingWorkerResponses = (message) => {
63
105
  for (const { tab, originalId } of pendingWorkerResponses.values()) {
64
- if (tab.readyState !== tab.OPEN)
65
- continue;
66
- tab.send(hostQueryError(originalId, -32000, message));
106
+ sendToTab(tab, hostQueryError(originalId, -32000, message), "worker-exit");
67
107
  }
68
108
  pendingWorkerResponses.clear();
109
+ pendingResponsesByTab.clear();
69
110
  };
111
+ const pendingResponseReaper = setInterval(() => {
112
+ const now = Date.now();
113
+ for (const [requestId, route] of pendingWorkerResponses) {
114
+ if (now - route.insertedAt < pendingResponseTtlMs)
115
+ continue;
116
+ deletePendingWorkerResponse(requestId);
117
+ sendToTab(route.tab, hostQueryError(route.originalId, -32000, "agent worker response timed out"), "worker-timeout");
118
+ }
119
+ }, pendingResponseReaperMs);
120
+ pendingResponseReaper.unref?.();
70
121
  // Notifications describe shared agent state and are broadcast. Correlated
71
122
  // JSON-RPC responses must return only to the tab that issued the request;
72
123
  // browser tabs all start their local counters at `web-1`, so broadcasting a
@@ -89,10 +140,8 @@ export async function startHeadlessServer(opts) {
89
140
  log("worker.response_dropped", { reason: "unknown request id", id: message.id });
90
141
  return;
91
142
  }
92
- pendingWorkerResponses.delete(String(message.id));
93
- if (route.tab.readyState !== route.tab.OPEN)
94
- return;
95
- route.tab.send(JSON.stringify({ ...message, id: route.originalId }));
143
+ deletePendingWorkerResponse(String(message.id));
144
+ sendToTab(route.tab, JSON.stringify({ ...message, id: route.originalId }), "worker-response");
96
145
  };
97
146
  const bridge = new WorkerBridgeCore({
98
147
  entryPath: opts.workerEntryPath,
@@ -171,7 +220,7 @@ export async function startHeadlessServer(opts) {
171
220
  return;
172
221
  serveStatic(req, res);
173
222
  });
174
- const wss = new WebSocketServer({ noServer: true });
223
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
175
224
  server.on("upgrade", (req, socket, head) => {
176
225
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
177
226
  if (pathname !== "/ws" || !passcode.allows(req)) {
@@ -184,6 +233,11 @@ export async function startHeadlessServer(opts) {
184
233
  tabs.add(ws);
185
234
  log("tab.connected", { tabs: tabs.size });
186
235
  ws.on("message", (data) => {
236
+ if (webSocketPayloadBytes(data) > 1024 * 1024) {
237
+ log("tab.frame_dropped", { reason: "payload too large" });
238
+ ws.close(1009, "payload too large");
239
+ return;
240
+ }
187
241
  const line = String(data);
188
242
  // Validate framing before touching the worker: a malformed frame from
189
243
  // one tab must never kill the shared pipe.
@@ -220,12 +274,18 @@ export async function startHeadlessServer(opts) {
220
274
  return;
221
275
  }
222
276
  if (workerMessage.id !== undefined) {
277
+ if ((pendingResponsesByTab.get(tabId) ?? 0) >= 64) {
278
+ ws.send(hostQueryError(workerMessage.id, -32000, "too many pending agent worker requests for this tab"));
279
+ return;
280
+ }
223
281
  const workerRequestId = `serve-${tabId}-${nextWorkerRequestId++}`;
224
282
  pendingWorkerResponses.set(workerRequestId, {
225
283
  tab: ws,
226
284
  originalId: workerMessage.id,
227
285
  tabId,
286
+ insertedAt: Date.now(),
228
287
  });
288
+ pendingResponsesByTab.set(tabId, (pendingResponsesByTab.get(tabId) ?? 0) + 1);
229
289
  workerMessage.id = workerRequestId;
230
290
  }
231
291
  const workerLine = JSON.stringify(workerMessage);
@@ -236,24 +296,31 @@ export async function startHeadlessServer(opts) {
236
296
  });
237
297
  ws.on("close", () => {
238
298
  tabs.delete(ws);
239
- for (const [requestId, route] of pendingWorkerResponses) {
240
- if (route.tabId === tabId)
241
- pendingWorkerResponses.delete(requestId);
242
- }
299
+ clearPendingWorkerResponsesForTab(tabId);
243
300
  log("tab.closed", { tabs: tabs.size });
244
301
  });
245
302
  ws.on("error", () => {
246
303
  tabs.delete(ws);
304
+ clearPendingWorkerResponsesForTab(tabId);
247
305
  });
248
306
  });
249
307
  });
250
- await new Promise((resolve, reject) => {
251
- server.once("error", reject);
252
- server.listen(opts.port ?? 8790, host, () => {
253
- server.off("error", reject);
254
- resolve();
308
+ try {
309
+ await new Promise((resolve, reject) => {
310
+ server.once("error", reject);
311
+ server.listen(opts.port ?? 8790, host, () => {
312
+ server.off("error", reject);
313
+ resolve();
314
+ });
255
315
  });
256
- });
316
+ }
317
+ catch (error) {
318
+ clearInterval(pendingResponseReaper);
319
+ bridge.kill();
320
+ wss.close();
321
+ server.close();
322
+ throw error;
323
+ }
257
324
  const address = server.address();
258
325
  const port = typeof address === "object" && address ? address.port : (opts.port ?? 8790);
259
326
  const url = `http://${host}:${port}`;
@@ -266,7 +333,9 @@ export async function startHeadlessServer(opts) {
266
333
  passcode,
267
334
  bridge,
268
335
  tabCount: () => tabs.size,
336
+ pendingResponseCount: () => pendingWorkerResponses.size,
269
337
  close: async () => {
338
+ clearInterval(pendingResponseReaper);
270
339
  for (const tab of tabs) {
271
340
  try {
272
341
  tab.terminate();
@@ -288,6 +357,18 @@ export async function startHeadlessServer(opts) {
288
357
  },
289
358
  };
290
359
  }
360
+ function webSocketPayloadBytes(data) {
361
+ if (typeof data === "string")
362
+ return Buffer.byteLength(data, "utf8");
363
+ if (Array.isArray(data)) {
364
+ return data.reduce((total, item) => total + (typeof item?.byteLength === "number" ? item.byteLength : 0), 0);
365
+ }
366
+ if (data && typeof data === "object" && "byteLength" in data) {
367
+ const byteLength = data.byteLength;
368
+ return typeof byteLength === "number" ? byteLength : Number.POSITIVE_INFINITY;
369
+ }
370
+ return Buffer.byteLength(String(data), "utf8");
371
+ }
291
372
  function replyToHostSessionQuery(message, sessionManager, workspaceCwd) {
292
373
  if (message.method !== "agent/query" || message.id === undefined)
293
374
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -39,9 +39,9 @@
39
39
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
40
40
  },
41
41
  "dependencies": {
42
- "@cjhyy/code-shell-capability-coding": "0.9.1",
43
- "@cjhyy/code-shell-core": "0.9.1",
44
- "@cjhyy/code-shell-web": "0.9.1",
42
+ "@cjhyy/code-shell-capability-coding": "0.9.3",
43
+ "@cjhyy/code-shell-core": "0.9.3",
44
+ "@cjhyy/code-shell-web": "0.9.3",
45
45
  "@types/ws": "^8.18.1",
46
46
  "ws": "^8.21.0"
47
47
  },