@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.
Files changed (57) hide show
  1. package/README.md +4 -0
  2. package/dist/desktop-web/http-api.d.ts +9 -0
  3. package/dist/desktop-web/http-api.js +17 -1
  4. package/dist/http-byte-range.d.ts +5 -0
  5. package/dist/http-byte-range.js +19 -0
  6. package/dist/hub/auth-store.d.ts +2 -0
  7. package/dist/hub/auth-store.js +26 -7
  8. package/dist/hub/files.js +71 -9
  9. package/dist/index.panels.d.ts +1 -0
  10. package/dist/index.panels.js +1 -0
  11. package/dist/mobile-remote/access-passcode.d.ts +5 -0
  12. package/dist/mobile-remote/access-passcode.js +48 -9
  13. package/dist/mobile-remote/codex-room-agent.js +3 -1
  14. package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
  15. package/dist/mobile-remote/mobile-chat-turn.js +15 -2
  16. package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
  17. package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
  18. package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
  19. package/dist/mobile-remote/mobile-static.d.ts +5 -6
  20. package/dist/mobile-remote/mobile-static.js +13 -14
  21. package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
  22. package/dist/mobile-remote/remote-host-manager.js +132 -56
  23. package/dist/mobile-remote/resident-agent.js +3 -1
  24. package/dist/mobile-remote/room-manager.d.ts +4 -0
  25. package/dist/mobile-remote/room-manager.js +5 -0
  26. package/dist/mobile-remote/room-process-stop.d.ts +8 -0
  27. package/dist/mobile-remote/room-process-stop.js +18 -0
  28. package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
  29. package/dist/mobile-remote/tunnel-manager.js +45 -20
  30. package/dist/panels/background-service-process.d.ts +39 -0
  31. package/dist/panels/background-service-process.js +457 -0
  32. package/dist/panels/background-service-store.d.ts +67 -0
  33. package/dist/panels/background-service-store.js +539 -0
  34. package/dist/panels/background-services.d.ts +193 -0
  35. package/dist/panels/background-services.js +1159 -0
  36. package/dist/panels/bridge-contract.d.ts +1 -1
  37. package/dist/panels/execution-gate.d.ts +10 -2
  38. package/dist/panels/execution-gate.js +36 -2
  39. package/dist/panels/http.d.ts +1 -0
  40. package/dist/panels/management-http.d.ts +1 -0
  41. package/dist/panels/management.d.ts +3 -0
  42. package/dist/panels/management.js +105 -21
  43. package/dist/panels/package-mutation.d.ts +1 -1
  44. package/dist/panels/package-mutation.js +3 -10
  45. package/dist/panels/process-files.js +28 -3
  46. package/dist/panels/resources/library.d.ts +1 -4
  47. package/dist/panels/resources/library.js +2 -18
  48. package/dist/panels/resources/references.js +21 -8
  49. package/dist/panels/runtime.js +1 -1
  50. package/dist/panels/tool-jobs.d.ts +2 -0
  51. package/dist/panels/tool-jobs.js +1 -1
  52. package/dist/serve/headless-server.js +113 -79
  53. package/dist/sessions-service.d.ts +4 -0
  54. package/dist/sessions-service.js +17 -0
  55. package/dist/static-files.d.ts +7 -0
  56. package/dist/static-files.js +114 -0
  57. 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 { readFileSync, existsSync } from "node:fs";
21
- import { basename, dirname, extname, join, resolve } from "node:path";
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 { contentTypeFor } from "../static-files.js";
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 = join(root, "index.html");
639
- if (!existsSync(filePath)) {
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
- try {
646
- const body = readFileSync(filePath);
647
- res.writeHead(200, {
648
- "content-type": contentTypeFor(extname(filePath)),
649
- "cache-control": "no-cache",
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 || !(await hubAuth.authenticate(req)))
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
- closing = true;
1191
- responses.clear();
1192
- await panelAutomations?.close();
1193
- clearInterval(pendingResponseReaper);
1194
- hostRuntime.stop();
1195
- if (authReaper)
1196
- clearInterval(authReaper);
1197
- await uploads?.close();
1198
- await skills?.close();
1199
- await mcp?.close();
1200
- configuration?.close();
1201
- links?.close();
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: async () => {
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;
@@ -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
@@ -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>;
@@ -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.47",
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.47",
66
- "@cjhyy/code-shell-core": "0.9.47",
67
- "@cjhyy/code-shell-web": "0.9.47",
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.47",
72
- "@cjhyy/code-shell-host": "0.9.47"
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"