@cjhyy/code-shell-server 0.9.47 → 0.9.49
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 +4 -0
- package/dist/desktop-web/http-api.d.ts +9 -0
- package/dist/desktop-web/http-api.js +17 -1
- package/dist/http-byte-range.d.ts +5 -0
- package/dist/http-byte-range.js +19 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +26 -7
- package/dist/hub/files.js +71 -9
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/mobile-remote/access-passcode.d.ts +5 -0
- package/dist/mobile-remote/access-passcode.js +48 -9
- package/dist/mobile-remote/codex-room-agent.js +3 -1
- package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
- package/dist/mobile-remote/mobile-chat-turn.js +15 -2
- package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
- package/dist/mobile-remote/mobile-static.d.ts +5 -6
- package/dist/mobile-remote/mobile-static.js +13 -14
- package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
- package/dist/mobile-remote/remote-host-manager.js +132 -56
- package/dist/mobile-remote/resident-agent.js +3 -1
- package/dist/mobile-remote/room-manager.d.ts +4 -0
- package/dist/mobile-remote/room-manager.js +5 -0
- package/dist/mobile-remote/room-process-stop.d.ts +8 -0
- package/dist/mobile-remote/room-process-stop.js +18 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
- package/dist/mobile-remote/tunnel-manager.js +45 -20
- package/dist/panels/background-service-process.d.ts +39 -0
- package/dist/panels/background-service-process.js +457 -0
- package/dist/panels/background-service-store.d.ts +67 -0
- package/dist/panels/background-service-store.js +539 -0
- package/dist/panels/background-services.d.ts +193 -0
- package/dist/panels/background-services.js +1159 -0
- package/dist/panels/bridge-contract.d.ts +1 -1
- package/dist/panels/execution-gate.d.ts +10 -2
- package/dist/panels/execution-gate.js +36 -2
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/management-http.d.ts +1 -0
- package/dist/panels/management.d.ts +3 -0
- package/dist/panels/management.js +105 -21
- package/dist/panels/package-mutation.d.ts +1 -1
- package/dist/panels/package-mutation.js +3 -10
- package/dist/panels/process-files.js +28 -3
- package/dist/panels/resources/library.d.ts +1 -4
- package/dist/panels/resources/library.js +2 -18
- package/dist/panels/resources/references.js +21 -8
- package/dist/panels/runtime.js +1 -1
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +1 -1
- package/dist/serve/headless-server.js +113 -79
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +17 -0
- package/dist/static-files.d.ts +7 -0
- package/dist/static-files.js +114 -0
- package/package.json +6 -6
|
@@ -17,15 +17,15 @@ import { describeEnvironment, environmentIdentity } from "../environment-identit
|
|
|
17
17
|
// server restart spawns a fresh worker on the first inbound frame and the
|
|
18
18
|
// browser re-lists sessions over the same protocol.
|
|
19
19
|
import { createServer } from "node:http";
|
|
20
|
-
import {
|
|
21
|
-
import { basename, dirname,
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
22
22
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
23
23
|
import { WebSocketServer } from "ws";
|
|
24
24
|
import { CONTRACTS, SessionManager, SettingsManager } from "@cjhyy/code-shell-core";
|
|
25
25
|
import { SessionOutputCoverageStore, readOutputJournal, readOutputJournalLegacyBase, } from "@cjhyy/code-shell-core/internal";
|
|
26
26
|
import { AccessPasscode } from "../mobile-remote/access-passcode.js";
|
|
27
27
|
import { resolveSafe } from "../mobile-remote/mobile-static.js";
|
|
28
|
-
import {
|
|
28
|
+
import { streamStaticFile } from "../static-files.js";
|
|
29
29
|
import { createHubAuth } from "../hub/auth-http.js";
|
|
30
30
|
import { ApprovalLeases } from "../hub/approval-lease.js";
|
|
31
31
|
import { HubUploads, hubJson } from "../hub/uploads.js";
|
|
@@ -611,7 +611,7 @@ export async function startHeadlessServer(opts) {
|
|
|
611
611
|
isAuthorized: configurationOptions.isAuthorized,
|
|
612
612
|
})
|
|
613
613
|
: undefined;
|
|
614
|
-
const serveStatic = (req, res) => {
|
|
614
|
+
const serveStatic = async (req, res) => {
|
|
615
615
|
const root = opts.staticRootDir;
|
|
616
616
|
if (!root) {
|
|
617
617
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
@@ -635,27 +635,24 @@ export async function startHeadlessServer(opts) {
|
|
|
635
635
|
res.end("not found");
|
|
636
636
|
return;
|
|
637
637
|
}
|
|
638
|
-
filePath =
|
|
639
|
-
if (!
|
|
638
|
+
filePath = resolveSafe(root, "index.html");
|
|
639
|
+
if (!filePath) {
|
|
640
640
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
641
641
|
res.end("web app not built");
|
|
642
642
|
return;
|
|
643
643
|
}
|
|
644
644
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
});
|
|
651
|
-
res.end(body);
|
|
652
|
-
}
|
|
653
|
-
catch {
|
|
654
|
-
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
655
|
-
res.end("not found");
|
|
656
|
-
}
|
|
645
|
+
await streamStaticFile(req, res, {
|
|
646
|
+
rootDir: root,
|
|
647
|
+
filePath,
|
|
648
|
+
cacheControl: "no-cache",
|
|
649
|
+
});
|
|
657
650
|
};
|
|
658
651
|
const handleHttp = async (req, res) => {
|
|
652
|
+
if (closing) {
|
|
653
|
+
hubJson(res, 503, { error: "server is closing" });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
659
656
|
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
660
657
|
res.setHeader("x-content-type-options", "nosniff");
|
|
661
658
|
res.setHeader("referrer-policy", "no-referrer");
|
|
@@ -678,14 +675,16 @@ export async function startHeadlessServer(opts) {
|
|
|
678
675
|
return;
|
|
679
676
|
}
|
|
680
677
|
if (hubAuth) {
|
|
681
|
-
if (await links?.handlePublic(req, res))
|
|
678
|
+
if ((await links?.handlePublic(req, res)) || closing || res.destroyed)
|
|
682
679
|
return;
|
|
683
|
-
if (await panels?.handleAssets(req, res))
|
|
680
|
+
if ((await panels?.handleAssets(req, res)) || closing || res.destroyed)
|
|
684
681
|
return;
|
|
685
|
-
if (await hubAuth.handle(req, res))
|
|
682
|
+
if ((await hubAuth.handle(req, res)) || closing || res.destroyed)
|
|
686
683
|
return;
|
|
687
684
|
if (pathname.startsWith("/api/")) {
|
|
688
685
|
const session = await hubAuth.authenticate(req);
|
|
686
|
+
if (closing || res.destroyed)
|
|
687
|
+
return;
|
|
689
688
|
if (!session) {
|
|
690
689
|
hubJson(res, 401, { error: "login required" });
|
|
691
690
|
return;
|
|
@@ -694,10 +693,6 @@ export async function startHeadlessServer(opts) {
|
|
|
694
693
|
hubJson(res, 403, { error: "origin rejected" });
|
|
695
694
|
return;
|
|
696
695
|
}
|
|
697
|
-
if (closing) {
|
|
698
|
-
hubJson(res, 503, { error: "server is closing" });
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
696
|
responses.track(session.id, res);
|
|
702
697
|
if (pathname === "/api/v1/environment" && req.method === "GET") {
|
|
703
698
|
hubJson(res, 200, environment);
|
|
@@ -738,20 +733,37 @@ export async function startHeadlessServer(opts) {
|
|
|
738
733
|
return;
|
|
739
734
|
}
|
|
740
735
|
// Public static shell contains no user data and supplies the setup/login page.
|
|
741
|
-
serveStatic(req, res);
|
|
736
|
+
await serveStatic(req, res);
|
|
742
737
|
return;
|
|
743
738
|
}
|
|
744
739
|
if (!passcode.gate(req, res))
|
|
745
740
|
return;
|
|
746
|
-
serveStatic(req, res);
|
|
741
|
+
await serveStatic(req, res);
|
|
747
742
|
};
|
|
748
743
|
const server = createServer((req, res) => {
|
|
749
744
|
void handleHttp(req, res).catch(() => hubJson(res, 500, { error: "server request failed" }));
|
|
750
745
|
});
|
|
746
|
+
// HTTP closeAllConnections excludes an upgrade awaiting authentication.
|
|
747
|
+
// Own accepted sockets until close or a completed transfer to WebSocket.
|
|
748
|
+
const connections = new Set();
|
|
749
|
+
server.on("connection", (socket) => {
|
|
750
|
+
connections.add(socket);
|
|
751
|
+
socket.once("close", () => connections.delete(socket));
|
|
752
|
+
if (closing)
|
|
753
|
+
socket.destroy();
|
|
754
|
+
});
|
|
751
755
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
|
752
756
|
const upgrade = async (req, socket, head) => {
|
|
757
|
+
if (closing || socket.destroyed) {
|
|
758
|
+
socket.destroy();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
753
761
|
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
754
762
|
const session = hubAuth ? await hubAuth.authenticate(req) : undefined;
|
|
763
|
+
if (closing || socket.destroyed) {
|
|
764
|
+
socket.destroy();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
755
767
|
const allowed = hubAuth
|
|
756
768
|
? !!session && hubAuth.isOriginAllowed(req)
|
|
757
769
|
: passcode.allows(req);
|
|
@@ -767,6 +779,7 @@ export async function startHeadlessServer(opts) {
|
|
|
767
779
|
}
|
|
768
780
|
const tabId = nextTabId++;
|
|
769
781
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
782
|
+
connections.delete(socket);
|
|
770
783
|
tabs.add(ws);
|
|
771
784
|
if (session)
|
|
772
785
|
tabAuth.set(ws, { sessionId: session.id, request: req });
|
|
@@ -792,7 +805,7 @@ export async function startHeadlessServer(opts) {
|
|
|
792
805
|
ws.close(1009, "payload too large");
|
|
793
806
|
return;
|
|
794
807
|
}
|
|
795
|
-
if (closing) {
|
|
808
|
+
if (closing || ws.readyState !== ws.OPEN) {
|
|
796
809
|
ws.close(1012, "server stopping");
|
|
797
810
|
return;
|
|
798
811
|
}
|
|
@@ -943,9 +956,13 @@ export async function startHeadlessServer(opts) {
|
|
|
943
956
|
sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, error.message), "attachments");
|
|
944
957
|
return;
|
|
945
958
|
}
|
|
946
|
-
if (ws.readyState !== ws.OPEN
|
|
959
|
+
if (closing || ws.readyState !== ws.OPEN)
|
|
960
|
+
return;
|
|
961
|
+
if (!(await hubAuth.authenticate(req)))
|
|
947
962
|
return;
|
|
948
963
|
}
|
|
964
|
+
if (closing || ws.readyState !== ws.OPEN)
|
|
965
|
+
return;
|
|
949
966
|
// Recheck after asynchronous staging/authentication; other tabs may
|
|
950
967
|
// have filled the runtime's capacity while this request was suspended.
|
|
951
968
|
if (hubAuth && parsed.method === "agent/run" && runningSessions.size >= 64) {
|
|
@@ -1060,6 +1077,8 @@ export async function startHeadlessServer(opts) {
|
|
|
1060
1077
|
const workerLine = JSON.stringify(workerMessage);
|
|
1061
1078
|
// Spawn-on-first-frame (idempotent): the browser's first request wakes
|
|
1062
1079
|
// the worker, mirroring the renderer's spawn-on-agent/run semantics.
|
|
1080
|
+
if (closing || ws.readyState !== ws.OPEN)
|
|
1081
|
+
return;
|
|
1063
1082
|
bridge.ensureWorker(opts.cwd);
|
|
1064
1083
|
if (!bridge.canSend()) {
|
|
1065
1084
|
workerUnavailable("agent worker is unavailable");
|
|
@@ -1114,6 +1133,58 @@ export async function startHeadlessServer(opts) {
|
|
|
1114
1133
|
server.on("upgrade", (req, socket, head) => {
|
|
1115
1134
|
void upgrade(req, socket, head).catch(() => socket.destroy());
|
|
1116
1135
|
});
|
|
1136
|
+
let closePromise;
|
|
1137
|
+
const close = () => {
|
|
1138
|
+
if (closePromise)
|
|
1139
|
+
return closePromise;
|
|
1140
|
+
closing = true;
|
|
1141
|
+
closePromise = Promise.resolve().then(async () => {
|
|
1142
|
+
// Stop admission before any asynchronous resource/worker cleanup. A
|
|
1143
|
+
// reconnect during a slow worker exit must not prolong server shutdown.
|
|
1144
|
+
const webSocketsClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
1145
|
+
// Finish WebSocket teardown before the HTTP close callback, including on
|
|
1146
|
+
// Bun where closing HTTP before its upgraded sockets can strand it.
|
|
1147
|
+
const httpClosed = webSocketsClosed.then(() => new Promise((resolve) => {
|
|
1148
|
+
server.closeAllConnections?.();
|
|
1149
|
+
server.close(() => resolve());
|
|
1150
|
+
}));
|
|
1151
|
+
for (const tab of tabs)
|
|
1152
|
+
tab.terminate();
|
|
1153
|
+
for (const socket of connections)
|
|
1154
|
+
socket.destroy();
|
|
1155
|
+
tabs.clear();
|
|
1156
|
+
responses.clear();
|
|
1157
|
+
clearInterval(pendingResponseReaper);
|
|
1158
|
+
if (authReaper)
|
|
1159
|
+
clearInterval(authReaper);
|
|
1160
|
+
// A failed cleanup must not leave the remaining services or worker alive.
|
|
1161
|
+
const errors = [];
|
|
1162
|
+
const clean = async (action) => {
|
|
1163
|
+
try {
|
|
1164
|
+
await action();
|
|
1165
|
+
}
|
|
1166
|
+
catch (error) {
|
|
1167
|
+
errors.push(error);
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
await clean(() => panelAutomations?.close());
|
|
1171
|
+
await clean(() => {
|
|
1172
|
+
hostRuntime.stop();
|
|
1173
|
+
});
|
|
1174
|
+
await clean(() => uploads?.close());
|
|
1175
|
+
await clean(() => skills?.close());
|
|
1176
|
+
await clean(() => mcp?.close());
|
|
1177
|
+
await clean(() => configuration?.close());
|
|
1178
|
+
await clean(() => links?.close());
|
|
1179
|
+
await clean(() => panels?.close());
|
|
1180
|
+
await clean(() => files?.close());
|
|
1181
|
+
await clean(() => bridge.stopAndWait());
|
|
1182
|
+
await Promise.all([httpClosed, webSocketsClosed]);
|
|
1183
|
+
if (errors.length)
|
|
1184
|
+
throw new AggregateError(errors, "Headless server shutdown failed");
|
|
1185
|
+
});
|
|
1186
|
+
return closePromise;
|
|
1187
|
+
};
|
|
1117
1188
|
try {
|
|
1118
1189
|
if (hubAuth) {
|
|
1119
1190
|
automationWorker = createHubAutomationWorker({
|
|
@@ -1187,23 +1258,18 @@ export async function startHeadlessServer(opts) {
|
|
|
1187
1258
|
});
|
|
1188
1259
|
}
|
|
1189
1260
|
catch (error) {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
await panels?.close();
|
|
1203
|
-
files?.close();
|
|
1204
|
-
await bridge.stopAndWait();
|
|
1205
|
-
wss.close();
|
|
1206
|
-
server.close();
|
|
1261
|
+
const failures = [error];
|
|
1262
|
+
try {
|
|
1263
|
+
await close();
|
|
1264
|
+
}
|
|
1265
|
+
catch (cleanupError) {
|
|
1266
|
+
failures.push(cleanupError);
|
|
1267
|
+
}
|
|
1268
|
+
if (failures.length > 1) {
|
|
1269
|
+
throw new AggregateError(failures, "Headless server startup and shutdown failed", {
|
|
1270
|
+
cause: error,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1207
1273
|
throw error;
|
|
1208
1274
|
}
|
|
1209
1275
|
const address = server.address();
|
|
@@ -1220,39 +1286,7 @@ export async function startHeadlessServer(opts) {
|
|
|
1220
1286
|
bridge,
|
|
1221
1287
|
tabCount: () => tabs.size,
|
|
1222
1288
|
pendingResponseCount: () => pendingWorkerResponses.size,
|
|
1223
|
-
close
|
|
1224
|
-
closing = true;
|
|
1225
|
-
responses.clear();
|
|
1226
|
-
await panelAutomations?.close();
|
|
1227
|
-
clearInterval(pendingResponseReaper);
|
|
1228
|
-
if (authReaper)
|
|
1229
|
-
clearInterval(authReaper);
|
|
1230
|
-
await uploads?.close();
|
|
1231
|
-
await skills?.close();
|
|
1232
|
-
await mcp?.close();
|
|
1233
|
-
configuration?.close();
|
|
1234
|
-
links?.close();
|
|
1235
|
-
await panels?.close();
|
|
1236
|
-
files?.close();
|
|
1237
|
-
for (const tab of tabs) {
|
|
1238
|
-
try {
|
|
1239
|
-
tab.terminate();
|
|
1240
|
-
}
|
|
1241
|
-
catch {
|
|
1242
|
-
/* ignore */
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
tabs.clear();
|
|
1246
|
-
await bridge.stopAndWait();
|
|
1247
|
-
await new Promise((resolve) => {
|
|
1248
|
-
wss.close(() => {
|
|
1249
|
-
server.close(() => resolve());
|
|
1250
|
-
});
|
|
1251
|
-
// server.close() alone waits out keep-alive HTTP sockets forever;
|
|
1252
|
-
// drop them so shutdown is prompt.
|
|
1253
|
-
server.closeAllConnections?.();
|
|
1254
|
-
});
|
|
1255
|
-
},
|
|
1289
|
+
close,
|
|
1256
1290
|
};
|
|
1257
1291
|
}
|
|
1258
1292
|
function webSocketPayloadBytes(data) {
|
|
@@ -38,6 +38,8 @@ export interface DiskSessionMeta {
|
|
|
38
38
|
engineSessionId: string;
|
|
39
39
|
cwd: string;
|
|
40
40
|
title: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
provider?: string;
|
|
41
43
|
updatedAt: number;
|
|
42
44
|
/** Durable creation time, when supplied by the engine's state schema. */
|
|
43
45
|
createdAt?: number;
|
|
@@ -69,6 +71,8 @@ export interface ListDiskSessionsResult {
|
|
|
69
71
|
export declare function listDiskSessions(opts: {
|
|
70
72
|
limit: number;
|
|
71
73
|
cursor?: string;
|
|
74
|
+
/** Display-only project roots filter, applied before pagination. */
|
|
75
|
+
cwds?: readonly string[];
|
|
72
76
|
includeArchived?: boolean;
|
|
73
77
|
/** Host read models may include children; ordinary sidebar queries stay top-level. */
|
|
74
78
|
includeSubagents?: boolean;
|
package/dist/sessions-service.js
CHANGED
|
@@ -217,6 +217,13 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
|
|
|
217
217
|
// Preserve the public adapter's explicit relative-root form. Host receives
|
|
218
218
|
// one absolute root; it never guesses a workspace for the caller.
|
|
219
219
|
baseDir = path.resolve(baseDir);
|
|
220
|
+
const pathKey = (cwd) => {
|
|
221
|
+
const normalized = path.resolve(cwd);
|
|
222
|
+
return process.platform === "darwin" || process.platform === "win32"
|
|
223
|
+
? normalized.toLocaleLowerCase("en-US")
|
|
224
|
+
: normalized;
|
|
225
|
+
};
|
|
226
|
+
const cwdRoots = opts.cwds?.map(pathKey);
|
|
220
227
|
const dirs = (await scanSessionDirectories(baseDir)).filter(({ id }) => !isInternalEphemeralSessionId(id));
|
|
221
228
|
dirs.sort(compareDiskSessionPosition);
|
|
222
229
|
const decodedCursor = decodeDiskSessionCursor(opts.cursor);
|
|
@@ -288,6 +295,14 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
|
|
|
288
295
|
// is intentionally NOT filtered here — the renderer handles that via
|
|
289
296
|
// isNoRepoCwd. (deleted-project resurrection)
|
|
290
297
|
const cwdStr = typeof state.cwd === "string" ? state.cwd : "";
|
|
298
|
+
if (cwdRoots &&
|
|
299
|
+
(!cwdStr ||
|
|
300
|
+
!cwdRoots.some((root) => {
|
|
301
|
+
const candidate = pathKey(cwdStr);
|
|
302
|
+
return (candidate === root ||
|
|
303
|
+
candidate.startsWith(root.endsWith(path.sep) ? root : root + path.sep));
|
|
304
|
+
})))
|
|
305
|
+
continue;
|
|
291
306
|
// A child transcript remains useful after its temporary worktree is gone;
|
|
292
307
|
// targeted reads do not rebuild projects in the sidebar.
|
|
293
308
|
const childParentId = typeof state.parentSessionId === "string" && state.parentSessionId
|
|
@@ -325,6 +340,8 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
|
|
|
325
340
|
? { clientMessageId: state.clientMessageId }
|
|
326
341
|
: {}),
|
|
327
342
|
engineSessionId: id,
|
|
343
|
+
...(typeof state.model === "string" ? { model: state.model } : {}),
|
|
344
|
+
...(typeof state.provider === "string" ? { provider: state.provider } : {}),
|
|
328
345
|
cwd: typeof state.cwd === "string" ? state.cwd : "",
|
|
329
346
|
// Prefer the LLM-generated title (now persisted to state); fall back to
|
|
330
347
|
// the raw first-message summary, then the id. This lets the disk rebuild
|
package/dist/static-files.d.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
1
2
|
export declare function contentTypeFor(extname: string): string;
|
|
3
|
+
/** Stream a pinned regular file, bounded by its opened size and the response lifetime. */
|
|
4
|
+
export declare function streamStaticFile(req: IncomingMessage, res: ServerResponse, options: {
|
|
5
|
+
rootDir: string;
|
|
6
|
+
filePath: string;
|
|
7
|
+
cacheControl: string;
|
|
8
|
+
}): Promise<void>;
|
package/dist/static-files.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// Shared static-file primitives for the two HTTP hosts (pairing host's
|
|
4
4
|
// /mobile static root and the headless serve SPA root). Keep this file free
|
|
5
5
|
// of host wiring — MIME map + helpers only.
|
|
6
|
+
import { constants } from "node:fs";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
9
|
+
import { extname, isAbsolute, relative, sep } from "node:path";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
6
11
|
const CONTENT_TYPES = {
|
|
7
12
|
".html": "text/html; charset=utf-8",
|
|
8
13
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -24,3 +29,112 @@ const CONTENT_TYPES = {
|
|
|
24
29
|
export function contentTypeFor(extname) {
|
|
25
30
|
return CONTENT_TYPES[extname.toLowerCase()] ?? "application/octet-stream";
|
|
26
31
|
}
|
|
32
|
+
/** RFC 9110 entity-tag lists allow commas inside opaque tags and empty list items. */
|
|
33
|
+
function matchesIfNoneMatch(value, etag) {
|
|
34
|
+
if (typeof value !== "string")
|
|
35
|
+
return false;
|
|
36
|
+
const header = value.replace(/^[ \t]+|[ \t]+$/g, "");
|
|
37
|
+
if (header === "*")
|
|
38
|
+
return true;
|
|
39
|
+
const tag = /(?:W\/)?("[\x21\x23-\x7e\x80-\xff]*")/y;
|
|
40
|
+
const opaque = etag.slice(2);
|
|
41
|
+
let position = 0;
|
|
42
|
+
let matches = false;
|
|
43
|
+
while (position < header.length) {
|
|
44
|
+
while (/[ \t,]/.test(header[position] ?? "") && position < header.length)
|
|
45
|
+
position++;
|
|
46
|
+
if (position === header.length)
|
|
47
|
+
break;
|
|
48
|
+
tag.lastIndex = position;
|
|
49
|
+
const item = tag.exec(header);
|
|
50
|
+
if (!item)
|
|
51
|
+
return false;
|
|
52
|
+
matches ||= item[1] === opaque;
|
|
53
|
+
position = tag.lastIndex;
|
|
54
|
+
while (/[ \t]/.test(header[position] ?? "") && position < header.length)
|
|
55
|
+
position++;
|
|
56
|
+
if (position < header.length && header[position++] !== ",")
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// Validate the entire field before accepting a match, including later items.
|
|
60
|
+
return matches;
|
|
61
|
+
}
|
|
62
|
+
/** Stream a pinned regular file, bounded by its opened size and the response lifetime. */
|
|
63
|
+
export async function streamStaticFile(req, res, options) {
|
|
64
|
+
let handle;
|
|
65
|
+
try {
|
|
66
|
+
if (res.destroyed || res.writableEnded)
|
|
67
|
+
return;
|
|
68
|
+
const root = await realpath(options.rootDir);
|
|
69
|
+
const path = await realpath(options.filePath);
|
|
70
|
+
const contained = (target) => {
|
|
71
|
+
const rel = relative(root, target);
|
|
72
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
73
|
+
};
|
|
74
|
+
if (!contained(path))
|
|
75
|
+
throw new Error("Static asset is outside its root");
|
|
76
|
+
// Resolve aliases before opening so valid in-root symlinks remain supported.
|
|
77
|
+
// O_NONBLOCK avoids hanging on a regular file replaced by a FIFO.
|
|
78
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
79
|
+
const info = await handle.stat({ bigint: true });
|
|
80
|
+
if (!info.isFile())
|
|
81
|
+
throw new Error("Static asset is not a regular file");
|
|
82
|
+
if (info.size > BigInt(Number.MAX_SAFE_INTEGER))
|
|
83
|
+
throw new Error("Static asset is too large");
|
|
84
|
+
const currentPath = await realpath(options.filePath);
|
|
85
|
+
const current = await stat(currentPath, { bigint: true });
|
|
86
|
+
if (!contained(currentPath) ||
|
|
87
|
+
(await realpath(options.rootDir)) !== root ||
|
|
88
|
+
current.dev !== info.dev ||
|
|
89
|
+
current.ino !== info.ino ||
|
|
90
|
+
current.size !== info.size ||
|
|
91
|
+
current.mtimeNs !== info.mtimeNs ||
|
|
92
|
+
current.ctimeNs !== info.ctimeNs)
|
|
93
|
+
throw new Error("Static asset changed while opening");
|
|
94
|
+
if (res.destroyed || res.writableEnded)
|
|
95
|
+
return;
|
|
96
|
+
// A weak validator describes the opened file's metadata, not a byte hash.
|
|
97
|
+
// Include identity and change time so replacement or an equal-size rewrite
|
|
98
|
+
// with a restored mtime does not reuse the previous representation's tag.
|
|
99
|
+
const fingerprint = createHash("sha256")
|
|
100
|
+
.update(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`)
|
|
101
|
+
.digest("base64url");
|
|
102
|
+
const etag = `W/"${fingerprint}"`;
|
|
103
|
+
const headers = {
|
|
104
|
+
"content-type": contentTypeFor(extname(options.filePath)),
|
|
105
|
+
"cache-control": options.cacheControl,
|
|
106
|
+
"x-content-type-options": "nosniff",
|
|
107
|
+
etag,
|
|
108
|
+
};
|
|
109
|
+
if ((req.method === "GET" || req.method === "HEAD") &&
|
|
110
|
+
matchesIfNoneMatch(req.headers["if-none-match"], etag)) {
|
|
111
|
+
res.writeHead(304, headers);
|
|
112
|
+
res.end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
res.writeHead(200, { ...headers, "content-length": info.size.toString() });
|
|
116
|
+
if (req.method === "HEAD" || info.size === 0n) {
|
|
117
|
+
res.end();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// pipeline propagates backpressure and destroys the source on disconnect.
|
|
121
|
+
// The explicit end prevents a growing file from extending this response.
|
|
122
|
+
await pipeline(handle.createReadStream({ autoClose: false, start: 0, end: Number(info.size - 1n) }), res);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
if (res.destroyed || res.writableEnded)
|
|
126
|
+
return;
|
|
127
|
+
if (res.headersSent)
|
|
128
|
+
res.destroy();
|
|
129
|
+
else {
|
|
130
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
131
|
+
res.end("not found");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await handle?.close().catch(() => {
|
|
136
|
+
if (!res.destroyed)
|
|
137
|
+
res.destroy();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
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.49",
|
|
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",
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@cjhyy/code-shell-capability-coding": "0.9.
|
|
66
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
67
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
65
|
+
"@cjhyy/code-shell-capability-coding": "0.9.49",
|
|
66
|
+
"@cjhyy/code-shell-core": "0.9.49",
|
|
67
|
+
"@cjhyy/code-shell-web": "0.9.49",
|
|
68
68
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
69
69
|
"@types/ws": "^8.18.1",
|
|
70
70
|
"ws": "^8.21.0",
|
|
71
|
-
"@cjhyy/code-shell-link": "0.9.
|
|
72
|
-
"@cjhyy/code-shell-host": "0.9.
|
|
71
|
+
"@cjhyy/code-shell-link": "0.9.49",
|
|
72
|
+
"@cjhyy/code-shell-host": "0.9.49"
|
|
73
73
|
},
|
|
74
74
|
"engines": {
|
|
75
75
|
"node": ">=20.10"
|