@hienlh/ppm 0.22.0 → 0.22.2

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 (147) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CLAUDE.md +1 -1
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/cli-reference.md +4 -4
  5. package/assets/skills/ppm/references/http-api.md +1 -1
  6. package/dist/web/assets/accounts-settings-section-CLEAA_Jc.js +1 -0
  7. package/dist/web/assets/{ai-resource-editor-CdDpOwjh.js → ai-resource-editor-C4ecid2g.js} +1 -1
  8. package/dist/web/assets/ai-settings-section-BOwGdiyH.js +1 -0
  9. package/dist/web/assets/ai-settings-section-Cqz13gRt.js +1 -0
  10. package/dist/web/assets/{api-client-C1ZwRL4Y.js → api-client-Jn3njtNw.js} +1 -1
  11. package/dist/web/assets/api-client-rSFnnCj4.js +1 -0
  12. package/dist/web/assets/{api-settings-_7Q-ULxp.js → api-settings-BxijvPBR.js} +1 -1
  13. package/dist/web/assets/{api-themes-DRAERo4Q.js → api-themes-CSUarkzD.js} +1 -1
  14. package/dist/web/assets/{appearance-settings-section-DLoPK0LD.js → appearance-settings-section-DbA8L-Vz.js} +1 -1
  15. package/dist/web/assets/{audio-preview-DsYvgKqj.js → audio-preview-CxJd8wRV.js} +1 -1
  16. package/dist/web/assets/{branch-review-tab-G84Ojzae.js → branch-review-tab-CMTa2uSR.js} +1 -1
  17. package/dist/web/assets/chat-tab-BpdgqjSL.js +15 -0
  18. package/dist/web/assets/{code-editor-D04iUPsZ.js → code-editor-B7y7-5KE.js} +4 -4
  19. package/dist/web/assets/{codex-daily-guard-BSccLRGT.js → codex-daily-guard-ChDPiXDx.js} +1 -1
  20. package/dist/web/assets/{conflict-editor-C4ay676e.js → conflict-editor-Axs9Ad7A.js} +3 -3
  21. package/dist/web/assets/{csv-preview-DbwEmG7o.js → csv-preview-DTUAW2HW.js} +1 -1
  22. package/dist/web/assets/{database-viewer-S6LsALjX.js → database-viewer-qXDiWeXj.js} +1 -1
  23. package/dist/web/assets/{diff-viewer-3oBA7vgQ.js → diff-viewer-BtLiSpqe.js} +1 -1
  24. package/dist/web/assets/{docx-preview-CXuk-emm.js → docx-preview-Dxvy6h13.js} +1 -1
  25. package/dist/web/assets/{editor-language-service-DJmaKVY0.js → editor-language-service-D75GuLXP.js} +1 -1
  26. package/dist/web/assets/{explorer-body-BQ0jyAGv.js → explorer-body-xg1-MhlW.js} +2 -2
  27. package/dist/web/assets/{explorer-window-content-jdkWt34J.js → explorer-window-content-BeQocW3i.js} +1 -1
  28. package/dist/web/assets/extension-manager-section-Bm2v3P-n.js +1 -0
  29. package/dist/web/assets/{extension-webview-BGSDUbdK.js → extension-webview-CzxrAHyL.js} +1 -1
  30. package/dist/web/assets/{file-icons-BXdvggK2.js → file-icons-wS-OmEVu.js} +1 -1
  31. package/dist/web/assets/{file-store-ojgMXgAw.js → file-store-Ds3qJ4hC.js} +1 -1
  32. package/dist/web/assets/{files-settings-section-Gnyd7han.js → files-settings-section-Bd-_GeZU.js} +1 -1
  33. package/dist/web/assets/{format-file-meta-C77h04cZ.js → format-file-meta-PPi3ptkm.js} +1 -1
  34. package/dist/web/assets/{general-settings-section-V6Khh15V.js → general-settings-section-CfNPHQUk.js} +1 -1
  35. package/dist/web/assets/{git-log-panel-Bi513qjT.js → git-log-panel-CtGxPzH6.js} +1 -1
  36. package/dist/web/assets/{git-repo-store-88Z8v-pF.js → git-repo-store-BCEPfUGT.js} +1 -1
  37. package/dist/web/assets/{glide-data-grid-DnQwYSmQ.js → glide-data-grid-iSo2vP0q.js} +1 -1
  38. package/dist/web/assets/{group-chat-tab-DpczdwyV.js → group-chat-tab-D1lx0zz-.js} +1 -1
  39. package/dist/web/assets/{image-preview-CzCc4_Do.js → image-preview-DeGD0HJu.js} +1 -1
  40. package/dist/web/assets/index-CN0G8uYY.js +52 -0
  41. package/dist/web/assets/{jira-watcher-section-f2HUbGDY.js → jira-watcher-section-kyifZPTv.js} +1 -1
  42. package/dist/web/assets/keybindings-store-L5vV8DEJ.js +1 -0
  43. package/dist/web/assets/{keyboard-shortcuts-section-BpvVEw_Y.js → keyboard-shortcuts-section-D2uJq4x9.js} +1 -1
  44. package/dist/web/assets/markdown-renderer-BX1Q00sC.js +3 -0
  45. package/dist/web/assets/markdown-renderer-CLUux0vA.js +1 -0
  46. package/dist/web/assets/{media-url-yfqHpPz0.js → media-url-DKK0YHxL.js} +1 -1
  47. package/dist/web/assets/{mobile-explorer-sheet-Bol3XAO1.js → mobile-explorer-sheet-BHQDzpwK.js} +1 -1
  48. package/dist/web/assets/notification-store-BHgSy-ci.js +1 -0
  49. package/dist/web/assets/panel-store-D20Zuf3i.js +1 -0
  50. package/dist/web/assets/{pdf-preview-CmBI6E7d.js → pdf-preview-DAcfhfGm.js} +1 -1
  51. package/dist/web/assets/{postgres-viewer-8ZAqEALv.js → postgres-viewer-DPRsmgP5.js} +1 -1
  52. package/dist/web/assets/{ppmbot-settings-section-DKJUA3Kq.js → ppmbot-settings-section-B5N1jHgm.js} +1 -1
  53. package/dist/web/assets/{problems-panel-CFcoUx8h.js → problems-panel-D_wZ4_Qg.js} +1 -1
  54. package/dist/web/assets/{process-table-EKr2dqG6.js → process-table-DahflyQn.js} +1 -1
  55. package/dist/web/assets/{project-framework-store-B70Q3dNM.js → project-framework-store-ViZgce6y.js} +1 -1
  56. package/dist/web/assets/{project-store-B87hLRSk.js → project-store-hNvwfqX3.js} +1 -1
  57. package/dist/web/assets/{proxy-settings-section-B7qRVl8w.js → proxy-settings-section-CLg_nlxn.js} +1 -1
  58. package/dist/web/assets/{query-audit-section-By6qyqdB.js → query-audit-section-BrfSo0s_.js} +1 -1
  59. package/dist/web/assets/{remote-desktop-clipboard-notice-0WDYsyCA.js → remote-desktop-clipboard-notice-Fi6cieGM.js} +1 -1
  60. package/dist/web/assets/{remote-desktop-mobile-sheet-COYDg16H.js → remote-desktop-mobile-sheet-lLZ-oK73.js} +2 -2
  61. package/dist/web/assets/{remote-desktop-mobile-view-2DqSuS2U.js → remote-desktop-mobile-view-j3v-4rYa.js} +1 -1
  62. package/dist/web/assets/{remote-desktop-readiness-gate-Dm_qMJMH.js → remote-desktop-readiness-gate-CeLvcX_w.js} +1 -1
  63. package/dist/web/assets/{remote-desktop-window-content-UodhsF7W.js → remote-desktop-window-content-lwbkLsTC.js} +1 -1
  64. package/dist/web/assets/{run-in-terminal-CdefEe_R.js → run-in-terminal-D7W4IKb9.js} +1 -1
  65. package/dist/web/assets/{schedules-settings-section-bG2lYfGv.js → schedules-settings-section-C8OS0o80.js} +1 -1
  66. package/dist/web/assets/{send-to-chat-F207yH3u.js → send-to-chat-DNKhmHbi.js} +1 -1
  67. package/dist/web/assets/{settings-body-C06bFarv.js → settings-body-DgN22Z0W.js} +2 -2
  68. package/dist/web/assets/{settings-store-krC6bc79.js → settings-store-CsVSVgLp.js} +2 -2
  69. package/dist/web/assets/{settings-tab-34RSaASd.js → settings-tab-ChOs94iB.js} +1 -1
  70. package/dist/web/assets/{settings-window-content-Dr_2lmHH.js → settings-window-content-BnKMmg2a.js} +1 -1
  71. package/dist/web/assets/{sql-query-editor-BgRH0cHD.js → sql-query-editor-BJZIIQsm.js} +1 -1
  72. package/dist/web/assets/{sqlite-viewer-DUxGslpa.js → sqlite-viewer--cMeCZum.js} +1 -1
  73. package/dist/web/assets/{system-monitor-body-IZuLn7Qh.js → system-monitor-body-CXiIrn0N.js} +2 -2
  74. package/dist/web/assets/system-monitor-tab-BJKrTSl0.js +1 -0
  75. package/dist/web/assets/{system-monitor-window-content-CEaFISgj.js → system-monitor-window-content-Dy9fZJE3.js} +1 -1
  76. package/dist/web/assets/{tab-host-window-content-D0TN8Wrq.js → tab-host-window-content-BezqrAwC.js} +1 -1
  77. package/dist/web/assets/{tab-store-OJa1GPCq.js → tab-store-krAuHQ6_.js} +1 -1
  78. package/dist/web/assets/{team-member-sheet-DDPNcCjd.js → team-member-sheet-CVM2QC3H.js} +2 -2
  79. package/dist/web/assets/{team-member-window-content-DCiW_1-E.js → team-member-window-content-D4RYKplm.js} +1 -1
  80. package/dist/web/assets/{terminal-tab-4ypP0NDY.js → terminal-tab-jikhQLct.js} +3 -3
  81. package/dist/web/assets/{tool-cards-Dd6E7Q3k.js → tool-cards-D5jCQvqn.js} +2 -2
  82. package/dist/web/assets/{use-blob-url-CPtCcY3i.js → use-blob-url-DMGlPCQD.js} +1 -1
  83. package/dist/web/assets/{use-inline-blame-vrfFHuaR.js → use-inline-blame-BF8Y1KfV.js} +3 -3
  84. package/dist/web/assets/{use-lsp-CsuZu49X.js → use-lsp-Bx4oVtlk.js} +1 -1
  85. package/dist/web/assets/{use-monaco-theme-BIVYJBIn.js → use-monaco-theme-DHczXLMb.js} +1 -1
  86. package/dist/web/assets/{use-websocket-BMbufhX7.js → use-websocket-BWZkbleJ.js} +1 -1
  87. package/dist/web/assets/{video-preview-Byj8P_jA.js → video-preview-D191TvX9.js} +1 -1
  88. package/dist/web/assets/{window-pip-registry-yD6esSju.js → window-pip-registry-D1oXq-Dq.js} +1 -1
  89. package/dist/web/assets/ws-auth-BG-cdrQ8.js +1 -0
  90. package/dist/web/index.html +13 -13
  91. package/dist/web/sw.js +1 -1
  92. package/docs/architecture/ai-chat-and-providers.md +9 -0
  93. package/docs/architecture/workspace-and-ui.md +13 -0
  94. package/package.json +1 -1
  95. package/packages/ext-git-graph/src/webview-html.test.ts +30 -5
  96. package/packages/ext-git-graph/src/webview-html.ts +99 -31
  97. package/src/cli/commands/init.ts +1 -0
  98. package/src/providers/codex-app-server/codex-history.ts +26 -7
  99. package/src/providers/codex-app-server/codex-provider.ts +6 -4
  100. package/src/server/routes/chat.ts +1 -1
  101. package/src/server/routes/settings.ts +11 -5
  102. package/src/services/config.service.ts +2 -0
  103. package/src/services/edge-forwarder.ts +191 -191
  104. package/src/services/windows-process-tree.ts +351 -351
  105. package/src/shared/codex-daily-guard.ts +12 -5
  106. package/src/types/chat.ts +1 -1
  107. package/src/types/config.ts +12 -1
  108. package/src/web/components/chat/chat-tab.tsx +162 -38
  109. package/src/web/components/chat/message-input.tsx +24 -0
  110. package/src/web/components/chat/model-thinking-selector.tsx +25 -5
  111. package/src/web/components/chat/new-chat-provider-gate.tsx +68 -0
  112. package/src/web/components/extensions/extension-tree-view.tsx +4 -11
  113. package/src/web/components/layout/command-palette.tsx +2 -18
  114. package/src/web/components/layout/editor-panel.tsx +1 -1
  115. package/src/web/components/layout/reparenting-tab.tsx +4 -2
  116. package/src/web/components/layout/tab-bar.tsx +1 -1
  117. package/src/web/components/settings/accounts/account-card.tsx +2 -2
  118. package/src/web/components/settings/accounts/codex-accounts-section.tsx +4 -4
  119. package/src/web/components/settings/ai-settings-section.tsx +36 -0
  120. package/src/web/components/settings/extension-manager-section.tsx +8 -1
  121. package/src/web/hooks/use-draft.ts +25 -4
  122. package/src/web/hooks/use-usage.ts +6 -4
  123. package/src/web/lib/api-client.ts +7 -1
  124. package/src/web/lib/api-settings.ts +1 -0
  125. package/src/web/lib/extension-icons.ts +70 -0
  126. package/src/web/lib/new-chat-provider.ts +8 -0
  127. package/src/web/lib/tab-type-icons.ts +14 -0
  128. package/src/web/stores/panel-store.ts +46 -3
  129. package/dist/web/assets/accounts-settings-section-BVr9-FZJ.js +0 -1
  130. package/dist/web/assets/ai-settings-section-B1hQyjAd.js +0 -1
  131. package/dist/web/assets/ai-settings-section-X2UOfSaS.js +0 -1
  132. package/dist/web/assets/api-client-pQL53cDQ.js +0 -1
  133. package/dist/web/assets/chat-tab-DQJfjm8E.js +0 -15
  134. package/dist/web/assets/extension-manager-section-DG1guILi.js +0 -1
  135. package/dist/web/assets/index-DU68xk06.js +0 -52
  136. package/dist/web/assets/keybindings-store-DCRsnKg5.js +0 -1
  137. package/dist/web/assets/markdown-renderer-Bcaf9_bA.js +0 -1
  138. package/dist/web/assets/markdown-renderer-DGJp8HC3.js +0 -3
  139. package/dist/web/assets/notification-store-BXt54VIn.js +0 -1
  140. package/dist/web/assets/panel-store-CboGJAwh.js +0 -1
  141. package/dist/web/assets/system-monitor-tab-DiSA490Z.js +0 -1
  142. package/dist/web/assets/ws-auth-CEYVQ8NH.js +0 -1
  143. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  144. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  145. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  146. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
  147. package/src/web/hooks/use-delayed-flag.ts +0 -35
@@ -5,9 +5,9 @@ import {
5
5
  validateAIProviderConfig,
6
6
  validateCodexContextConfig,
7
7
  validateDefaultProvider,
8
- VALID_PROVIDERS,
9
8
  DEFAULT_CONFIG,
10
9
  type AIProviderConfig,
10
+ type NewChatProviderMode,
11
11
  type TelegramConfig,
12
12
  type PPMBotConfig,
13
13
  type ThemeConfig,
@@ -184,12 +184,20 @@ settingsRoutes.put("/ai", async (c) => {
184
184
  try {
185
185
  const body = await c.req.json<{
186
186
  default_provider?: string;
187
+ new_chat_provider_mode?: NewChatProviderMode;
187
188
  share_provider_context?: boolean;
188
189
  providers?: Record<string, Partial<AIProviderConfig>>;
189
190
  }>();
190
191
 
191
192
  const currentAi = configService.get("ai");
192
193
 
194
+ if ("new_chat_provider_mode" in body && body.new_chat_provider_mode !== "default" && body.new_chat_provider_mode !== "follow-focus") {
195
+ return c.json(err("new_chat_provider_mode must be one of: default, follow-focus"), 400);
196
+ }
197
+ if ("default_provider" in body && (typeof body.default_provider !== "string" || !body.default_provider)) {
198
+ return c.json(err("default_provider must be a non-empty string"), 400);
199
+ }
200
+
193
201
  if ("share_provider_context" in body && typeof body.share_provider_context !== "boolean") {
194
202
  return c.json(err("share_provider_context must be a boolean"), 400);
195
203
  }
@@ -207,6 +215,7 @@ settingsRoutes.put("/ai", async (c) => {
207
215
  // Merge: body overrides current values (shallow merge per provider)
208
216
  const updated = {
209
217
  ...currentAi,
218
+ new_chat_provider_mode: body.new_chat_provider_mode ?? currentAi.new_chat_provider_mode ?? "default",
210
219
  share_provider_context: body.share_provider_context ?? currentAi.share_provider_context ?? true,
211
220
  ...(body.default_provider && { default_provider: body.default_provider }),
212
221
  };
@@ -231,11 +240,8 @@ settingsRoutes.put("/ai", async (c) => {
231
240
  }
232
241
  }
233
242
 
234
- // Validate default_provider is in allowed list and references existing provider
243
+ // Configured providers include runtime integrations such as Codex.
235
244
  if (body.default_provider) {
236
- if (!VALID_PROVIDERS.includes(body.default_provider as any)) {
237
- return c.json(err(`default_provider must be one of: ${VALID_PROVIDERS.join(", ")}`), 400);
238
- }
239
245
  const dpErr = validateDefaultProvider(updated.default_provider, updated.providers);
240
246
  if (dpErr) return c.json(err(dpErr), 400);
241
247
  }
@@ -167,6 +167,8 @@ class ConfigService {
167
167
  dbProjects: { path: string; name: string; color: string | null }[],
168
168
  ): PpmConfig {
169
169
  const config = structuredClone(DEFAULT_CONFIG);
170
+ // Existing installs without an AI row must also retain the old tab behavior.
171
+ config.ai.new_chat_provider_mode = "default";
170
172
  for (const [key, jsonValue] of Object.entries(dbRows)) {
171
173
  if (key in config && key !== "projects") {
172
174
  try {
@@ -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
+ }