@hienlh/ppm 0.23.4 → 0.23.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.23.5] - 2026-09-27
4
+
5
+ ### Improved
6
+ - New Codex chats reply about 2.5 s sooner, and starting one no longer freezes the whole server for a couple of seconds.
7
+ - The Codex session list loads about 4x faster.
8
+
3
9
  ## [0.23.4] - 2026-09-27
4
10
 
5
11
  ### Improved
@@ -71,4 +71,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
71
71
  - Third-party extensions (inspect via `ppm ext list`).
72
72
  - The Claude Agent SDK internals (separate skill).
73
73
 
74
- <!-- Generated for PPM v0.23.4 at build time. Re-run `ppm export skill --install` to refresh. -->
74
+ <!-- Generated for PPM v0.23.5 at build time. Re-run `ppm export skill --install` to refresh. -->
@@ -781,7 +781,7 @@ Show recent runs for a schedule
781
781
  Manage and inspect discovered skills & commands
782
782
 
783
783
  **Options:**
784
- - `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm-release-0.23.4"`)
784
+ - `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm"`)
785
785
 
786
786
  **Usage:** `ppm skills [options] [command]`
787
787
 
@@ -791,7 +791,7 @@ List all discovered skills and commands
791
791
 
792
792
  **Options:**
793
793
  - `--json` — JSON output
794
- - `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm-release-0.23.4"`)
794
+ - `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm"`)
795
795
 
796
796
  ### `ppm skills search`
797
797
 
@@ -799,7 +799,7 @@ Fuzzy search skills and commands
799
799
 
800
800
  **Options:**
801
801
  - `--json` — JSON output
802
- - `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm-release-0.23.4"`)
802
+ - `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm"`)
803
803
 
804
804
  **Usage:** `ppm skills search [options] <query>`
805
805
 
@@ -809,7 +809,7 @@ Show detailed info for a specific skill
809
809
 
810
810
  **Options:**
811
811
  - `--json` — JSON output
812
- - `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm-release-0.23.4"`)
812
+ - `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm"`)
813
813
 
814
814
  **Usage:** `ppm skills info [options] <name>`
815
815
 
@@ -359,4 +359,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
359
359
  - `ws://<host>/ws/terminal` — PTY terminal multiplexer
360
360
  - `ws://<host>/ws/extensions` — extension host channel
361
361
 
362
- <!-- Generated from src/server/routes/ for PPM v0.23.4 -->
362
+ <!-- Generated from src/server/routes/ for PPM v0.23.5 -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.23.4",
3
+ "version": "0.23.5",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -1,6 +1,6 @@
1
1
  import type { ChatMessage, ChatEvent, SessionInfo } from "../provider.interface.ts";
2
2
  import { stripSharedContext } from "../../shared/provider-context.ts";
3
- import { readdirSync, readFileSync, statSync, realpathSync } from "node:fs";
3
+ import { closeSync, fstatSync, openSync, readdirSync, readFileSync, readSync, statSync, realpathSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { getPpmDir } from "../../services/ppm-dir.ts";
@@ -221,11 +221,46 @@ export function parseRolloutJsonl(
221
221
  return messages;
222
222
  }
223
223
 
224
- /** Read a rollout file's header (session_meta, plus its title when asked). */
225
- function readSessionMeta(file: string, opts?: { withTitle?: boolean }): RolloutHeader | null {
224
+ /** First read of a rollout's head: `session_meta` carries the base instructions, measured at 22–27 KB. */
225
+ const HEADER_FIRST_READ_BYTES = 32 * 1024;
226
+
227
+ /**
228
+ * Read a rollout file's header (session_meta, plus its title when asked) from as little of
229
+ * the file as answers it.
230
+ *
231
+ * Reading whole files here is what froze the server: identifying one rollout among thousands
232
+ * read every transcript in full (1.8 GB, over a second of synchronous I/O) whenever no file
233
+ * name matched. The head is read in growing slices instead, and a slice's unterminated last
234
+ * line — possibly a split multi-byte character — is dropped by `completeLines`, so a partial
235
+ * read never parses a partial record.
236
+ */
237
+ function readSessionMeta(
238
+ file: string,
239
+ opts?: { withTitle?: boolean; titleIf?: (header: RolloutHeader) => boolean },
240
+ ): RolloutHeader | null {
241
+ let fd: number | undefined;
226
242
  try {
227
- return readRolloutHeader(readFileSync(file, "utf-8"), opts);
243
+ fd = openSync(file, "r");
244
+ const size = fstatSync(fd).size;
245
+ let buffer = Buffer.alloc(Math.min(size, HEADER_FIRST_READ_BYTES));
246
+ let filled = 0;
247
+ while (true) {
248
+ const read = readSync(fd, buffer, filled, buffer.length - filled, filled);
249
+ filled += read;
250
+ // A file that shrank under us reads short; stop rather than wait for bytes that are gone.
251
+ const eof = filled >= size || read === 0;
252
+ const header = readRolloutHeader(buffer.toString("utf-8", 0, filled), opts);
253
+ // A header the caller is about to discard needs no title: most rollouts in a shared
254
+ // tree belong to other projects, and a title can sit megabytes in, or nowhere at all.
255
+ const wanted = header && (!opts?.titleIf || opts.titleIf(header));
256
+ const complete = header && (!opts?.withTitle || !wanted || header.title || header.parentThreadId);
257
+ if (complete || eof) return header;
258
+ const grown = Buffer.alloc(Math.min(size, buffer.length * 4));
259
+ buffer.copy(grown, 0, 0, filled);
260
+ buffer = grown;
261
+ }
228
262
  } catch { /* unreadable file → excluded (fail-closed) */ }
263
+ finally { if (fd !== undefined) closeSync(fd); }
229
264
  return null;
230
265
  }
231
266
 
@@ -270,7 +305,10 @@ export function listCodexRollouts(
270
305
  const sessions: SessionInfo[] = [];
271
306
 
272
307
  for (const file of files) {
273
- const meta = readSessionMeta(file, { withTitle: true });
308
+ const meta = readSessionMeta(file, {
309
+ withTitle: true,
310
+ titleIf: (h) => !!h.cwd && !h.parentThreadId && normPath(h.cwd) === target,
311
+ });
274
312
  if (!meta?.cwd) continue; // fail-closed: no cwd → exclude
275
313
  if (normPath(meta.cwd) !== target) continue;
276
314
  if (meta.parentThreadId) continue; // one step of another session, not a session
@@ -887,8 +887,12 @@ export class CodexAppServerProvider implements AIProvider {
887
887
 
888
888
  // Only resume a rollout attributable to this project. An unknown/resumed ID
889
889
  // without one must not silently become a fresh thread with a different identity.
890
- const found = locateRollout(sessionId, cwd);
891
- if (!found && !this.unstartedSessions.has(sessionId)) throw missingRolloutError(sessionId);
890
+ // A session created here and never started has no rollout: codex names the thread with an
891
+ // id of its own, so no file can match this one. Looking anyway reads the head of every
892
+ // rollout on disk, synchronously, before the first turn can start.
893
+ const unstarted = this.unstartedSessions.has(sessionId);
894
+ const found = unstarted ? null : locateRollout(sessionId, cwd);
895
+ if (!found && !unstarted) throw missingRolloutError(sessionId);
892
896
 
893
897
  const client = new CodexJsonRpcClient();
894
898
  const channel = createEventChannel();
@@ -948,11 +952,13 @@ export class CodexAppServerProvider implements AIProvider {
948
952
  if (account) setSessionCodexAccount(threadId, account.id);
949
953
  }
950
954
  // Snapshot persisted history so live message ids continue the rollout-N
951
- // numbering (empty for a brand-new thread; full prior transcript on resume).
952
- live.history = fromCodexSessionsDirs(threadId, (d) => {
955
+ // numbering (empty for a brand-new thread; full prior transcript on resume). A new thread
956
+ // has nothing to snapshot, and its rollout may not be on disk yet — searching for it would
957
+ // fall back to reading the head of every other rollout.
958
+ live.history = found ? fromCodexSessionsDirs(threadId, (d) => {
953
959
  const msgs = getRolloutMessages(d, threadId, cwd);
954
960
  return msgs.length > 0 ? msgs : null;
955
- }) ?? [];
961
+ }) ?? [] : [];
956
962
  return live;
957
963
  }
958
964
 
@@ -131,6 +131,12 @@ interface SessionEntry {
131
131
  idleSince?: number;
132
132
  /** When the last turn completed — the moment this session's prompt cache was last written */
133
133
  lastTurnEndedAt?: number;
134
+ /**
135
+ * When the message that opened the current turn reached the server — what "time to first
136
+ * event" is measured from. The consumer loop outlives turns, so its own clock would count
137
+ * the idle gap before a follow-up as waiting on the provider.
138
+ */
139
+ turnRequestedAt?: { at: number; cold: boolean };
134
140
  /** Pending release of the subprocess once its prompt cache lapses */
135
141
  cacheReleaseTimer?: ReturnType<typeof setTimeout>;
136
142
  }
@@ -716,9 +722,13 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
716
722
  const isMetadataEvent = evType === "account_info" || evType === "account_retry" || evType === "streaming_status" || evType === "status_update";
717
723
  if (!firstEventReceived && !isMetadataEvent) {
718
724
  firstEventReceived = true;
719
- const waitMs = Date.now() - startTime;
720
- console.log(`[chat] session=${sessionId} first SDK event after ${waitMs}ms: type=${evType}`);
721
- logSessionEvent(sessionId, "PERF", `First SDK event after ${waitMs}ms (type=${evType})`);
725
+ const requested = entry.turnRequestedAt;
726
+ entry.turnRequestedAt = undefined;
727
+ // A turn nobody sent over this socket (scheduler, remote trigger) has no receipt time.
728
+ const waitMs = Date.now() - (requested?.at ?? startTime);
729
+ const path = requested ? (requested.cold ? "cold" : "warm") : "unsent";
730
+ console.log(`[chat] session=${sessionId} first SDK event after ${waitMs}ms: type=${evType} path=${path}`);
731
+ logSessionEvent(sessionId, "PERF", `First SDK event after ${waitMs}ms (type=${evType}, ${path})`);
722
732
  if (heartbeat) clearInterval(heartbeat);
723
733
  const newPhase = evType === "thinking" ? "thinking" : "streaming";
724
734
  setPhase(sessionId, newPhase);
@@ -1197,6 +1207,8 @@ export const chatWebSocket = {
1197
1207
  }
1198
1208
 
1199
1209
  if (parsed.type === "message") {
1210
+ // Taken before any awaited work below (slash rewrites, resume), which is part of the wait.
1211
+ const messageReceivedAt = Date.now();
1200
1212
  // Images count as content: a message may carry only a picture, with nothing typed.
1201
1213
  const hasInlineImages = Array.isArray((parsed as { images?: unknown }).images)
1202
1214
  && ((parsed as { images: unknown[] }).images.length > 0);
@@ -1320,6 +1332,10 @@ export const chatWebSocket = {
1320
1332
 
1321
1333
  // Store user message for reconnect replay (turn_events includes only assistant events)
1322
1334
  entry.currentUserMessage = parsed.content;
1335
+ // Only a message that opens a turn is timed; one typed mid-turn joins the running turn.
1336
+ if (!entry.isStreamingActive || entry.phase === "idle") {
1337
+ entry.turnRequestedAt = { at: messageReceivedAt, cold: !entry.isStreamingActive };
1338
+ }
1323
1339
 
1324
1340
  if (!entry.isStreamingActive) {
1325
1341
  // First message or post-crash recovery: start persistent consumer
@@ -1467,6 +1483,9 @@ export const chatWebSocket = {
1467
1483
  broadcastBackgroundRegistry(sessionId);
1468
1484
  const provider = providerRegistry.get(providerId);
1469
1485
  const instruction = `Call the KillShell tool with task_id "${shellId}" to stop that background command, then reply with just "Stopped.".`;
1486
+ if (!entry.isStreamingActive || entry.phase === "idle") {
1487
+ entry.turnRequestedAt = { at: Date.now(), cold: !entry.isStreamingActive };
1488
+ }
1470
1489
  if (!entry.isStreamingActive) {
1471
1490
  if (provider && "resumeSession" in provider) await (provider as any).resumeSession(sessionId);
1472
1491
  if (entry.projectPath && provider && "ensureProjectPath" in provider) {
@@ -1,191 +1,191 @@
1
- /**
2
- * Edge forwarder — owns the public port and pipes raw TCP to the server's
3
- * current loopback port.
4
- *
5
- * ─── CRITICAL INVARIANT: THIS PROCESS MUST NEVER SPAWN A CHILD ───
6
- *
7
- * On Windows a child spawned with fd stdio inherits every inheritable handle,
8
- * including a listening socket. When such a descendant is orphaned it keeps the
9
- * socket open, so the port stays in LISTEN under a dead PID and can never be
10
- * rebound — a "zombie port". The server hits this constantly because it spawns
11
- * chat/tool/MCP subprocesses; those inherit its listener and wedge its port.
12
- *
13
- * The edge exists precisely because it spawns nothing, so its socket cannot be
14
- * inherited and its port cannot zombie. cloudflared can therefore stay pinned
15
- * to it forever and the public URL stops rotating. Adding any child-process
16
- * call to this file reintroduces the exact bug the edge was built to remove.
17
- *
18
- * Raw TCP, never HTTP: WebSocket upgrades and SSE streams pass through
19
- * untouched, and forwarded requests keep the headers cloudflared set.
20
- */
21
- import net from "node:net";
22
- import { resolveTargetPort, _resetTargetCache } from "./edge-target-resolver.ts";
23
-
24
- /** Delay between attempts while waiting for the server to come back. */
25
- const CONNECT_RETRY_MS = 250;
26
- /**
27
- * How long a client connection waits for a reachable server before giving up.
28
- * During an upgrade the server is briefly absent; refusing instantly would
29
- * surface as a broken page rather than a slow one.
30
- */
31
- const CONNECT_WINDOW_MS = 5000;
32
-
33
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
34
-
35
- /** Resolve once the socket is connected; reject on the first connect error. */
36
- function connectUpstream(port: number): Promise<net.Socket> {
37
- return new Promise((resolvePromise, reject) => {
38
- const upstream = net.connect({
39
- port,
40
- host: "127.0.0.1", // never taken from status.json — loopback only
41
- allowHalfOpen: true,
42
- });
43
- const onError = (err: Error) => {
44
- upstream.destroy();
45
- reject(err);
46
- };
47
- upstream.once("error", onError);
48
- upstream.once("connect", () => {
49
- upstream.removeListener("error", onError);
50
- resolvePromise(upstream);
51
- });
52
- });
53
- }
54
-
55
- /**
56
- * Largest amount of client data held while waiting for the server. Bounds the
57
- * memory a slow upload can pin during an upgrade window.
58
- */
59
- const MAX_PENDING_BYTES = 8 * 1024 * 1024;
60
-
61
- /**
62
- * Pipe one client connection to the server.
63
- *
64
- * Resolving the upstream is async, so the first bytes usually arrive before
65
- * there is anywhere to send them. They must be captured by a `data` listener
66
- * attached synchronously on this very tick and replayed once the upstream
67
- * exists.
68
- *
69
- * `socket.pause()` is NOT a substitute — verified against Bun 1.3.13: bytes
70
- * that arrive while paused are lost, and a later `pipe()`/`resume()` does not
71
- * bring them back. A socket with no `data` listener drops them outright too.
72
- * Both variants silently swallowed the first request in testing.
73
- */
74
- async function handleConnection(
75
- client: net.Socket,
76
- connectWindowMs: number,
77
- ): Promise<void> {
78
- const pending: Buffer[] = [];
79
- let pendingBytes = 0;
80
- let clientEnded = false;
81
- const onEarlyData = (chunk: Buffer) => {
82
- pendingBytes += chunk.length;
83
- if (pendingBytes > MAX_PENDING_BYTES) {
84
- client.destroy();
85
- return;
86
- }
87
- pending.push(chunk);
88
- };
89
- client.on("data", onEarlyData);
90
- client.once("end", () => { clientEnded = true; });
91
-
92
- let upstream: net.Socket | null = null;
93
- let clientGone = false;
94
-
95
- // A socket error with no listener is an unhandled 'error' event, which would
96
- // take the whole edge process — and the public port — down with it.
97
- const teardown = () => {
98
- upstream?.destroy();
99
- client.destroy();
100
- };
101
- client.on("error", teardown);
102
- client.once("close", () => { clientGone = true; });
103
-
104
- const deadline = Date.now() + connectWindowMs;
105
- while (!clientGone && Date.now() < deadline) {
106
- const port = resolveTargetPort();
107
- if (port !== null) {
108
- try {
109
- upstream = await connectUpstream(port);
110
- break;
111
- } catch {
112
- // Server not up yet (or moved mid-restart) — re-resolve and retry.
113
- _resetTargetCache();
114
- }
115
- }
116
- await sleep(CONNECT_RETRY_MS);
117
- }
118
-
119
- if (clientGone) return;
120
- if (!upstream) {
121
- client.destroy();
122
- return;
123
- }
124
-
125
- upstream.on("error", teardown);
126
-
127
- // Replay what arrived during the connect, then hand both directions to the
128
- // stream machinery. Removing the listener and piping happen in the same tick,
129
- // so no chunk can slip through the gap.
130
- client.off("data", onEarlyData);
131
- for (const chunk of pending) upstream.write(chunk);
132
- pending.length = 0;
133
-
134
- client.pipe(upstream);
135
- upstream.pipe(client);
136
-
137
- // The client may have half-closed while we were still connecting.
138
- if (clientEnded) upstream.end();
139
- }
140
-
141
- export interface EdgeForwarderOptions {
142
- publicPort: number;
143
- host?: string;
144
- /** How long a client waits for a reachable server. Defaults to 5s. */
145
- connectWindowMs?: number;
146
- }
147
-
148
- /**
149
- * Bind the public port and start forwarding. Resolves once listening so the
150
- * caller can fail fast when the port is unavailable.
151
- */
152
- export function startEdgeForwarder(
153
- opts: EdgeForwarderOptions,
154
- ): Promise<net.Server> {
155
- const host = opts.host ?? "0.0.0.0";
156
- const connectWindowMs = opts.connectWindowMs ?? CONNECT_WINDOW_MS;
157
- return new Promise((resolvePromise, reject) => {
158
- // allowHalfOpen keeps the other direction alive when one side sends FIN —
159
- // long-lived chat WS and SSE streams depend on it.
160
- const server = net.createServer({ allowHalfOpen: true }, (client) => {
161
- void handleConnection(client, connectWindowMs);
162
- });
163
- server.once("error", reject);
164
- server.listen(opts.publicPort, host, () => {
165
- server.removeListener("error", reject);
166
- resolvePromise(server);
167
- });
168
- });
169
- }
170
-
171
- // ─── Process entry ─────────────────────────────────────────────────────
172
- // Mirrors the `__serve__` guard in src/server/index.ts: the supervisor
173
- // re-invokes the binary as `<bin> __edge__ <publicPort> <host>`.
174
- if (process.argv.includes("__edge__")) {
175
- const idx = process.argv.indexOf("__edge__");
176
- const publicPort = parseInt(process.argv[idx + 1] ?? "", 10);
177
- const host = process.argv[idx + 2] ?? "0.0.0.0";
178
-
179
- if (!Number.isInteger(publicPort) || publicPort <= 0 || publicPort > 65535) {
180
- process.stderr.write(`[edge] Invalid public port: ${process.argv[idx + 1]}\n`);
181
- process.exit(2);
182
- }
183
-
184
- try {
185
- await startEdgeForwarder({ publicPort, host });
186
- process.stderr.write(`[edge] Listening on ${host}:${publicPort}\n`);
187
- } catch (err) {
188
- process.stderr.write(`[edge] Failed to bind ${host}:${publicPort}: ${err}\n`);
189
- process.exit(1);
190
- }
191
- }
1
+ /**
2
+ * Edge forwarder — owns the public port and pipes raw TCP to the server's
3
+ * current loopback port.
4
+ *
5
+ * ─── CRITICAL INVARIANT: THIS PROCESS MUST NEVER SPAWN A CHILD ───
6
+ *
7
+ * On Windows a child spawned with fd stdio inherits every inheritable handle,
8
+ * including a listening socket. When such a descendant is orphaned it keeps the
9
+ * socket open, so the port stays in LISTEN under a dead PID and can never be
10
+ * rebound — a "zombie port". The server hits this constantly because it spawns
11
+ * chat/tool/MCP subprocesses; those inherit its listener and wedge its port.
12
+ *
13
+ * The edge exists precisely because it spawns nothing, so its socket cannot be
14
+ * inherited and its port cannot zombie. cloudflared can therefore stay pinned
15
+ * to it forever and the public URL stops rotating. Adding any child-process
16
+ * call to this file reintroduces the exact bug the edge was built to remove.
17
+ *
18
+ * Raw TCP, never HTTP: WebSocket upgrades and SSE streams pass through
19
+ * untouched, and forwarded requests keep the headers cloudflared set.
20
+ */
21
+ import net from "node:net";
22
+ import { resolveTargetPort, _resetTargetCache } from "./edge-target-resolver.ts";
23
+
24
+ /** Delay between attempts while waiting for the server to come back. */
25
+ const CONNECT_RETRY_MS = 250;
26
+ /**
27
+ * How long a client connection waits for a reachable server before giving up.
28
+ * During an upgrade the server is briefly absent; refusing instantly would
29
+ * surface as a broken page rather than a slow one.
30
+ */
31
+ const CONNECT_WINDOW_MS = 5000;
32
+
33
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
34
+
35
+ /** Resolve once the socket is connected; reject on the first connect error. */
36
+ function connectUpstream(port: number): Promise<net.Socket> {
37
+ return new Promise((resolvePromise, reject) => {
38
+ const upstream = net.connect({
39
+ port,
40
+ host: "127.0.0.1", // never taken from status.json — loopback only
41
+ allowHalfOpen: true,
42
+ });
43
+ const onError = (err: Error) => {
44
+ upstream.destroy();
45
+ reject(err);
46
+ };
47
+ upstream.once("error", onError);
48
+ upstream.once("connect", () => {
49
+ upstream.removeListener("error", onError);
50
+ resolvePromise(upstream);
51
+ });
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Largest amount of client data held while waiting for the server. Bounds the
57
+ * memory a slow upload can pin during an upgrade window.
58
+ */
59
+ const MAX_PENDING_BYTES = 8 * 1024 * 1024;
60
+
61
+ /**
62
+ * Pipe one client connection to the server.
63
+ *
64
+ * Resolving the upstream is async, so the first bytes usually arrive before
65
+ * there is anywhere to send them. They must be captured by a `data` listener
66
+ * attached synchronously on this very tick and replayed once the upstream
67
+ * exists.
68
+ *
69
+ * `socket.pause()` is NOT a substitute — verified against Bun 1.3.13: bytes
70
+ * that arrive while paused are lost, and a later `pipe()`/`resume()` does not
71
+ * bring them back. A socket with no `data` listener drops them outright too.
72
+ * Both variants silently swallowed the first request in testing.
73
+ */
74
+ async function handleConnection(
75
+ client: net.Socket,
76
+ connectWindowMs: number,
77
+ ): Promise<void> {
78
+ const pending: Buffer[] = [];
79
+ let pendingBytes = 0;
80
+ let clientEnded = false;
81
+ const onEarlyData = (chunk: Buffer) => {
82
+ pendingBytes += chunk.length;
83
+ if (pendingBytes > MAX_PENDING_BYTES) {
84
+ client.destroy();
85
+ return;
86
+ }
87
+ pending.push(chunk);
88
+ };
89
+ client.on("data", onEarlyData);
90
+ client.once("end", () => { clientEnded = true; });
91
+
92
+ let upstream: net.Socket | null = null;
93
+ let clientGone = false;
94
+
95
+ // A socket error with no listener is an unhandled 'error' event, which would
96
+ // take the whole edge process — and the public port — down with it.
97
+ const teardown = () => {
98
+ upstream?.destroy();
99
+ client.destroy();
100
+ };
101
+ client.on("error", teardown);
102
+ client.once("close", () => { clientGone = true; });
103
+
104
+ const deadline = Date.now() + connectWindowMs;
105
+ while (!clientGone && Date.now() < deadline) {
106
+ const port = resolveTargetPort();
107
+ if (port !== null) {
108
+ try {
109
+ upstream = await connectUpstream(port);
110
+ break;
111
+ } catch {
112
+ // Server not up yet (or moved mid-restart) — re-resolve and retry.
113
+ _resetTargetCache();
114
+ }
115
+ }
116
+ await sleep(CONNECT_RETRY_MS);
117
+ }
118
+
119
+ if (clientGone) return;
120
+ if (!upstream) {
121
+ client.destroy();
122
+ return;
123
+ }
124
+
125
+ upstream.on("error", teardown);
126
+
127
+ // Replay what arrived during the connect, then hand both directions to the
128
+ // stream machinery. Removing the listener and piping happen in the same tick,
129
+ // so no chunk can slip through the gap.
130
+ client.off("data", onEarlyData);
131
+ for (const chunk of pending) upstream.write(chunk);
132
+ pending.length = 0;
133
+
134
+ client.pipe(upstream);
135
+ upstream.pipe(client);
136
+
137
+ // The client may have half-closed while we were still connecting.
138
+ if (clientEnded) upstream.end();
139
+ }
140
+
141
+ export interface EdgeForwarderOptions {
142
+ publicPort: number;
143
+ host?: string;
144
+ /** How long a client waits for a reachable server. Defaults to 5s. */
145
+ connectWindowMs?: number;
146
+ }
147
+
148
+ /**
149
+ * Bind the public port and start forwarding. Resolves once listening so the
150
+ * caller can fail fast when the port is unavailable.
151
+ */
152
+ export function startEdgeForwarder(
153
+ opts: EdgeForwarderOptions,
154
+ ): Promise<net.Server> {
155
+ const host = opts.host ?? "0.0.0.0";
156
+ const connectWindowMs = opts.connectWindowMs ?? CONNECT_WINDOW_MS;
157
+ return new Promise((resolvePromise, reject) => {
158
+ // allowHalfOpen keeps the other direction alive when one side sends FIN —
159
+ // long-lived chat WS and SSE streams depend on it.
160
+ const server = net.createServer({ allowHalfOpen: true }, (client) => {
161
+ void handleConnection(client, connectWindowMs);
162
+ });
163
+ server.once("error", reject);
164
+ server.listen(opts.publicPort, host, () => {
165
+ server.removeListener("error", reject);
166
+ resolvePromise(server);
167
+ });
168
+ });
169
+ }
170
+
171
+ // ─── Process entry ─────────────────────────────────────────────────────
172
+ // Mirrors the `__serve__` guard in src/server/index.ts: the supervisor
173
+ // re-invokes the binary as `<bin> __edge__ <publicPort> <host>`.
174
+ if (process.argv.includes("__edge__")) {
175
+ const idx = process.argv.indexOf("__edge__");
176
+ const publicPort = parseInt(process.argv[idx + 1] ?? "", 10);
177
+ const host = process.argv[idx + 2] ?? "0.0.0.0";
178
+
179
+ if (!Number.isInteger(publicPort) || publicPort <= 0 || publicPort > 65535) {
180
+ process.stderr.write(`[edge] Invalid public port: ${process.argv[idx + 1]}\n`);
181
+ process.exit(2);
182
+ }
183
+
184
+ try {
185
+ await startEdgeForwarder({ publicPort, host });
186
+ process.stderr.write(`[edge] Listening on ${host}:${publicPort}\n`);
187
+ } catch (err) {
188
+ process.stderr.write(`[edge] Failed to bind ${host}:${publicPort}: ${err}\n`);
189
+ process.exit(1);
190
+ }
191
+ }