@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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
server.
|
|
254
|
-
|
|
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.
|
|
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.
|
|
43
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
44
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
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
|
},
|