@bitkyc08/opencodex 2.58.0 → 2.59.0

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 (188) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
@@ -0,0 +1,631 @@
1
+ import net, { type Socket } from "node:net";
2
+ import tls, { type TLSSocket } from "node:tls";
3
+
4
+ const DEFAULT_SOCKS5_PORT = 1080;
5
+ const SOCKS5_CONNECT_TIMEOUT_MS = 30_000;
6
+ const SOCKS5_RESPONSE_TIMEOUT_MS = 200_000;
7
+ const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
8
+ const MAX_BODY_SLICE_BYTES = 64 * 1024;
9
+ const SOCKS5_VERSION = 0x05;
10
+ const SOCKS5_NO_AUTH = 0x00;
11
+ const SOCKS5_USER_PASS = 0x02;
12
+ const SOCKS5_CONNECT = 0x01;
13
+ const SOCKS5_DOMAIN = 0x03;
14
+ const SOCKS5_SUCCESS = 0x00;
15
+ const CRLF = Buffer.from("\r\n");
16
+ const HEADER_END = Buffer.from("\r\n\r\n");
17
+
18
+ export class Socks5FetchError extends Error {
19
+ override readonly name = "Socks5FetchError";
20
+ }
21
+
22
+ function proxyCredentials(proxy: URL): { username?: Uint8Array; password?: Uint8Array } {
23
+ if (!proxy.username && !proxy.password) return {};
24
+ let username: string;
25
+ let password: string;
26
+ try {
27
+ username = decodeURIComponent(proxy.username);
28
+ password = decodeURIComponent(proxy.password);
29
+ } catch {
30
+ throw new Socks5FetchError("SOCKS5 proxy credentials contain invalid percent encoding");
31
+ }
32
+ const usernameBytes = new TextEncoder().encode(username);
33
+ const passwordBytes = new TextEncoder().encode(password);
34
+ if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) {
35
+ throw new Socks5FetchError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes");
36
+ }
37
+ return { username: usernameBytes, password: passwordBytes };
38
+ }
39
+
40
+ function validateProxy(proxy: string): URL {
41
+ let parsed: URL;
42
+ try {
43
+ parsed = new URL(proxy);
44
+ } catch {
45
+ throw new Socks5FetchError("SOCKS5 proxy URL is invalid");
46
+ }
47
+ if (parsed.protocol !== "socks5:" && parsed.protocol !== "socks5h:") {
48
+ throw new Socks5FetchError(`unsupported SOCKS5 proxy protocol: ${parsed.protocol}`);
49
+ }
50
+ if (!parsed.hostname) throw new Socks5FetchError("SOCKS5 proxy URL has no host");
51
+ if (parsed.port && (!/^\d+$/.test(parsed.port) || Number(parsed.port) < 1 || Number(parsed.port) > 65535)) {
52
+ throw new Socks5FetchError("SOCKS5 proxy port is invalid");
53
+ }
54
+ if (parsed.search || parsed.hash) throw new Socks5FetchError("SOCKS5 proxy URL must not contain a query or fragment");
55
+ proxyCredentials(parsed);
56
+ return parsed;
57
+ }
58
+
59
+ function targetPort(target: URL): number {
60
+ if (target.port) return Number(target.port);
61
+ return target.protocol === "https:" ? 443 : 80;
62
+ }
63
+
64
+ function connectSocket(hostname: string, port: number, signal?: AbortSignal): Promise<Socket> {
65
+ return new Promise((resolve, reject) => {
66
+ if (signal?.aborted) {
67
+ reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
68
+ return;
69
+ }
70
+ const socket = net.createConnection({ host: hostname, port });
71
+ let settled = false;
72
+ let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
73
+ fail(new Socks5FetchError("SOCKS5 proxy connection timed out"));
74
+ }, SOCKS5_CONNECT_TIMEOUT_MS);
75
+ const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
76
+ const onConnect = () => {
77
+ if (settled) return;
78
+ settled = true;
79
+ if (timer !== undefined) clearTimeout(timer);
80
+ timer = undefined;
81
+ cleanup();
82
+ resolve(socket);
83
+ };
84
+ const onError = (error: Error) => fail(error);
85
+ const onClose = () => fail(new Socks5FetchError("SOCKS5 proxy closed before connecting"));
86
+ const cleanup = () => {
87
+ signal?.removeEventListener("abort", onAbort);
88
+ socket.removeListener("connect", onConnect);
89
+ socket.removeListener("error", onError);
90
+ socket.removeListener("close", onClose);
91
+ };
92
+ const fail = (error: unknown) => {
93
+ if (settled) return;
94
+ settled = true;
95
+ if (timer !== undefined) clearTimeout(timer);
96
+ timer = undefined;
97
+ cleanup();
98
+ socket.destroy();
99
+ reject(error instanceof Error ? error : new Error(String(error)));
100
+ };
101
+ socket.once("connect", onConnect);
102
+ socket.once("error", onError);
103
+ socket.once("close", onClose);
104
+ signal?.addEventListener("abort", onAbort, { once: true });
105
+ });
106
+ }
107
+
108
+ class SocketReader {
109
+ private buffer = Buffer.alloc(0);
110
+ private ended = false;
111
+ private failure: Error | undefined;
112
+ private readonly exactWaiters: Array<{
113
+ length: number;
114
+ resolve: (value: Buffer) => void;
115
+ reject: (error: unknown) => void;
116
+ }> = [];
117
+ private readonly anyWaiters: Array<{
118
+ resolve: (value: Buffer) => void;
119
+ reject: (error: unknown) => void;
120
+ }> = [];
121
+
122
+ constructor(private readonly socket: Socket) {
123
+ socket.on("data", this.onData);
124
+ socket.once("error", this.onError);
125
+ socket.once("end", this.onEnd);
126
+ socket.once("close", this.onEnd);
127
+ socket.resume();
128
+ }
129
+
130
+ private readonly onData = (chunk: Buffer | string): void => {
131
+ const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
132
+ if (this.anyWaiters.length > 0) {
133
+ this.anyWaiters.shift()!.resolve(value);
134
+ return;
135
+ }
136
+ this.buffer = Buffer.concat([this.buffer, value]);
137
+ this.flushExact();
138
+ if (this.buffer.byteLength >= MAX_BODY_SLICE_BYTES) this.socket.pause();
139
+ };
140
+
141
+ private readonly onError = (error: Error): void => {
142
+ this.failure = error;
143
+ this.ended = true;
144
+ this.rejectExact(error);
145
+ this.rejectAny(error);
146
+ };
147
+
148
+ private readonly onEnd = (): void => {
149
+ this.ended = true;
150
+ this.rejectExact(this.failure ?? new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived"));
151
+ if (this.failure) this.rejectAny(this.failure);
152
+ else while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.resolve(Buffer.alloc(0));
153
+ };
154
+
155
+ private flushExact(): void {
156
+ while (this.exactWaiters.length > 0 && this.buffer.byteLength >= this.exactWaiters[0]!.length) {
157
+ const waiter = this.exactWaiters.shift()!;
158
+ const value = this.buffer.subarray(0, waiter.length);
159
+ this.buffer = this.buffer.subarray(waiter.length);
160
+ waiter.resolve(value);
161
+ }
162
+ }
163
+
164
+ private rejectExact(error: unknown): void {
165
+ while (this.exactWaiters.length > 0) this.exactWaiters.shift()!.reject(error);
166
+ }
167
+
168
+ private rejectAny(error: unknown): void {
169
+ while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.reject(error);
170
+ }
171
+
172
+ read(length: number, signal?: AbortSignal): Promise<Buffer> {
173
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("The operation was aborted"));
174
+ if (this.buffer.byteLength >= length) {
175
+ const value = this.buffer.subarray(0, length);
176
+ this.buffer = this.buffer.subarray(length);
177
+ if (this.buffer.byteLength < MAX_BODY_SLICE_BYTES) this.socket.resume();
178
+ return Promise.resolve(value);
179
+ }
180
+ if (this.ended) return Promise.reject(this.failure ?? new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived"));
181
+ return new Promise((resolve, reject) => {
182
+ const onAbort = () => {
183
+ const index = this.exactWaiters.indexOf(waiter);
184
+ if (index >= 0) this.exactWaiters.splice(index, 1);
185
+ waiter.reject(signal?.reason ?? new Error("The operation was aborted"));
186
+ };
187
+ // Store this exact object so cancellation removes the queued waiter.
188
+ const waiter = {
189
+ length,
190
+ resolve: (value: Buffer) => { signal?.removeEventListener("abort", onAbort); resolve(value); },
191
+ reject: (error: unknown) => { signal?.removeEventListener("abort", onAbort); reject(error); },
192
+ };
193
+ this.exactWaiters.push(waiter);
194
+ signal?.addEventListener("abort", onAbort, { once: true });
195
+ if (signal?.aborted) onAbort();
196
+ else this.socket.resume();
197
+ });
198
+ }
199
+
200
+ async readUntil(delimiter: Buffer, maxBytes: number, signal?: AbortSignal): Promise<Buffer> {
201
+ // Keep partial framing outside the shared buffer: readAny must wait for NEW
202
+ // bytes instead of returning the same unterminated prefix in a microtask loop.
203
+ let pending = Buffer.alloc(0);
204
+ for (;;) {
205
+ const chunk = await this.readAny(signal);
206
+ if (chunk.byteLength === 0) throw new Socks5FetchError("SOCKS5 upstream closed before response headers");
207
+ pending = Buffer.concat([pending, chunk]);
208
+ const index = pending.indexOf(delimiter);
209
+ if (index >= 0) {
210
+ const end = index + delimiter.byteLength;
211
+ if (end > maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large");
212
+ // Another data event may have arrived while the read promise resumed.
213
+ this.buffer = Buffer.concat([pending.subarray(end), this.buffer]);
214
+ if (this.buffer.byteLength >= MAX_BODY_SLICE_BYTES) this.socket.pause();
215
+ return pending.subarray(0, end);
216
+ }
217
+ if (pending.byteLength >= maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large");
218
+ }
219
+ }
220
+
221
+ readAny(signal?: AbortSignal): Promise<Buffer> {
222
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("The operation was aborted"));
223
+ if (this.buffer.byteLength > 0) {
224
+ const value = this.buffer;
225
+ this.buffer = Buffer.alloc(0);
226
+ this.socket.resume();
227
+ return Promise.resolve(value);
228
+ }
229
+ if (this.failure) return Promise.reject(this.failure);
230
+ if (this.ended) return Promise.resolve(Buffer.alloc(0));
231
+ return new Promise((resolve, reject) => {
232
+ const onAbort = () => {
233
+ const index = this.anyWaiters.indexOf(waiter);
234
+ if (index >= 0) this.anyWaiters.splice(index, 1);
235
+ waiter.reject(signal?.reason ?? new Error("The operation was aborted"));
236
+ };
237
+ const waiter = {
238
+ resolve: (value: Buffer) => { signal?.removeEventListener("abort", onAbort); resolve(value); },
239
+ reject: (error: unknown) => { signal?.removeEventListener("abort", onAbort); reject(error); },
240
+ };
241
+ this.anyWaiters.push(waiter);
242
+ signal?.addEventListener("abort", onAbort, { once: true });
243
+ if (signal?.aborted) onAbort();
244
+ else this.socket.resume();
245
+ });
246
+ }
247
+
248
+ dispose(): void {
249
+ this.socket.pause();
250
+ this.socket.removeListener("data", this.onData);
251
+ this.socket.removeListener("error", this.onError);
252
+ this.socket.removeListener("end", this.onEnd);
253
+ this.socket.removeListener("close", this.onEnd);
254
+ if (this.buffer.byteLength > 0) this.socket.unshift(this.buffer);
255
+ this.buffer = Buffer.alloc(0);
256
+ this.rejectExact(new Socks5FetchError("SOCKS5 reader disposed"));
257
+ this.rejectAny(new Socks5FetchError("SOCKS5 reader disposed"));
258
+ }
259
+ }
260
+
261
+ async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise<Socket> {
262
+ const parsedProxy = validateProxy(proxy);
263
+ const credentials = proxyCredentials(parsedProxy);
264
+ const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, "");
265
+ const socket = await connectSocket(proxyHost, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal);
266
+ socket.setTimeout(SOCKS5_CONNECT_TIMEOUT_MS, () => {
267
+ socket.destroy(new Socks5FetchError("SOCKS5 handshake timed out"));
268
+ });
269
+ const reader = new SocketReader(socket);
270
+ try {
271
+ const methods = credentials.username ? Buffer.from([SOCKS5_NO_AUTH, SOCKS5_USER_PASS]) : Buffer.from([SOCKS5_NO_AUTH]);
272
+ socket.write(Buffer.from([SOCKS5_VERSION, methods.byteLength, ...methods]));
273
+ const greeting = await reader.read(2, signal);
274
+ if (greeting[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid greeting");
275
+ if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) {
276
+ socket.write(Buffer.from([
277
+ 0x01,
278
+ credentials.username.byteLength,
279
+ ...credentials.username,
280
+ credentials.password.byteLength,
281
+ ...credentials.password,
282
+ ]));
283
+ const auth = await reader.read(2, signal);
284
+ if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5FetchError("SOCKS5 proxy authentication failed");
285
+ } else if (greeting[1] !== SOCKS5_NO_AUTH) {
286
+ throw new Socks5FetchError("SOCKS5 proxy does not accept an offered authentication method");
287
+ }
288
+
289
+ const hostname = new TextEncoder().encode(target.hostname);
290
+ if (hostname.byteLength > 255) throw new Socks5FetchError("SOCKS5 target hostname is too long");
291
+ const port = targetPort(target);
292
+ socket.write(Buffer.from([
293
+ SOCKS5_VERSION,
294
+ SOCKS5_CONNECT,
295
+ 0x00,
296
+ SOCKS5_DOMAIN,
297
+ hostname.byteLength,
298
+ ...hostname,
299
+ port >> 8,
300
+ port & 0xff,
301
+ ]));
302
+ const reply = await reader.read(4, signal);
303
+ if (reply[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid connect response");
304
+ if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5FetchError(`SOCKS5 proxy refused the connection (code ${reply[1]})`);
305
+ if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) {
306
+ throw new Socks5FetchError("SOCKS5 proxy returned an invalid address type or reserved byte");
307
+ }
308
+ const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.read(1, signal))[0]! : 16;
309
+ await reader.read(addressLength + 2, signal);
310
+ socket.setTimeout(0);
311
+ return socket;
312
+ } catch (error) {
313
+ socket.destroy();
314
+ throw error;
315
+ } finally {
316
+ reader.dispose();
317
+ }
318
+ }
319
+
320
+ function requestHeaders(request: Request, target: URL): { text: string; chunked: boolean } {
321
+ const headers = new Headers(request.headers);
322
+ if (!headers.has("host")) headers.set("host", target.host);
323
+ if (!headers.has("connection")) headers.set("connection", "close");
324
+ // This transport assembles the body itself, so a content-coding it did not ask for becomes
325
+ // its own problem to undo. Ask for none by default and leave an explicit caller choice
326
+ // alone: `fetchWithHeaderTimeout` selects identity for some streaming calls and preserves a
327
+ // caller's selection elsewhere, and silently overriding that would make the two disagree.
328
+ if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
329
+ const chunked = request.body !== null
330
+ && !headers.has("content-length")
331
+ && !headers.has("transfer-encoding");
332
+ if (chunked) headers.set("transfer-encoding", "chunked");
333
+ return {
334
+ text: [...headers.entries()].map(([key, value]) => `${key}: ${value}\r\n`).join(""),
335
+ chunked,
336
+ };
337
+ }
338
+
339
+ async function secureSocket(socket: Socket, target: URL, signal: AbortSignal): Promise<TLSSocket> {
340
+ return new Promise((resolve, reject) => {
341
+ const tlsSocket = tls.connect({
342
+ socket,
343
+ servername: target.hostname,
344
+ rejectUnauthorized: true,
345
+ });
346
+ const onAbort = () => {
347
+ tlsSocket.destroy();
348
+ reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
349
+ };
350
+ const onSecureConnect = () => {
351
+ signal.removeEventListener("abort", onAbort);
352
+ resolve(tlsSocket);
353
+ };
354
+ tlsSocket.once("secureConnect", onSecureConnect);
355
+ tlsSocket.once("error", error => {
356
+ signal.removeEventListener("abort", onAbort);
357
+ reject(error);
358
+ });
359
+ signal.addEventListener("abort", onAbort, { once: true });
360
+ });
361
+ }
362
+
363
+ function parseChunkSize(line: string): number {
364
+ const token = line.split(";", 1)[0]!.trim();
365
+ if (!/^[0-9a-fA-F]+$/.test(token)) {
366
+ throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk size");
367
+ }
368
+ const size = Number.parseInt(token, 16);
369
+ if (!Number.isSafeInteger(size) || size < 0) {
370
+ throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk size");
371
+ }
372
+ return size;
373
+ }
374
+
375
+ function parseResponseHead(raw: Buffer): { status: number; statusText: string; headers: Headers } {
376
+ const text = raw.toString("latin1");
377
+ const lines = text.split("\r\n");
378
+ const statusLine = lines.shift() ?? "";
379
+ const match = /^HTTP\/\d(?:\.\d)?\s+(\d{3})(?:\s+(.*))?$/.exec(statusLine);
380
+ if (!match) throw new Socks5FetchError("SOCKS5 upstream returned an invalid HTTP response");
381
+ const headers = new Headers();
382
+ for (const line of lines) {
383
+ if (!line) continue;
384
+ const separator = line.indexOf(":");
385
+ if (separator <= 0) throw new Socks5FetchError("SOCKS5 upstream returned an invalid HTTP header");
386
+ headers.append(line.slice(0, separator), line.slice(separator + 1).trim());
387
+ }
388
+ return { status: Number(match[1]), statusText: match[2] ?? "", headers };
389
+ }
390
+
391
+ function waitForDrain(socket: Socket, signal: AbortSignal): Promise<void> {
392
+ if (socket.destroyed) return Promise.reject(new Socks5FetchError("SOCKS5 socket closed while sending the request"));
393
+ if (signal.aborted) {
394
+ return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
395
+ }
396
+ return new Promise((resolve, reject) => {
397
+ let settled = false;
398
+ const cleanup = () => {
399
+ socket.removeListener("drain", onDrain);
400
+ socket.removeListener("error", onError);
401
+ socket.removeListener("close", onClose);
402
+ signal.removeEventListener("abort", onAbort);
403
+ };
404
+ const finish = (error?: unknown) => {
405
+ if (settled) return;
406
+ settled = true;
407
+ cleanup();
408
+ if (error === undefined) resolve();
409
+ else reject(error instanceof Error ? error : new Error(String(error)));
410
+ };
411
+ const onDrain = () => finish();
412
+ const onError = (error: Error) => finish(error);
413
+ const onClose = () => finish(new Socks5FetchError("SOCKS5 socket closed while sending the request"));
414
+ const onAbort = () => finish(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
415
+ socket.once("drain", onDrain);
416
+ socket.once("error", onError);
417
+ socket.once("close", onClose);
418
+ signal.addEventListener("abort", onAbort, { once: true });
419
+ if (socket.destroyed) onClose();
420
+ else if (signal.aborted) onAbort();
421
+ });
422
+ }
423
+
424
+ function responseBody(
425
+ reader: SocketReader,
426
+ socket: Socket,
427
+ signal: AbortSignal,
428
+ headers: Headers,
429
+ status: number,
430
+ method: string,
431
+ ): ReadableStream<Uint8Array> | null {
432
+ if (bodylessResponse(method, status)) {
433
+ reader.dispose();
434
+ socket.setTimeout(0);
435
+ socket.destroy();
436
+ return null;
437
+ }
438
+ const lengthHeader = headers.get("content-length");
439
+ const contentLength = lengthHeader === null ? undefined : Number(lengthHeader);
440
+ if (contentLength !== undefined && (!Number.isSafeInteger(contentLength) || contentLength < 0)) {
441
+ throw new Socks5FetchError("SOCKS5 upstream returned an invalid content-length");
442
+ }
443
+ const chunked = (headers.get("transfer-encoding") ?? "").toLowerCase().split(",").some(value => value.trim() === "chunked");
444
+ let remaining = contentLength;
445
+ let chunkRemaining = 0;
446
+ let complete = false;
447
+ const finish = () => {
448
+ if (complete) return;
449
+ complete = true;
450
+ signal.removeEventListener("abort", onAbort);
451
+ reader.dispose();
452
+ socket.setTimeout(0);
453
+ socket.destroy();
454
+ };
455
+ const onAbort = () => socket.destroy();
456
+ signal.addEventListener("abort", onAbort, { once: true });
457
+ const readChunk = async (): Promise<Buffer | null> => {
458
+ if (chunked) {
459
+ if (chunkRemaining === 0) {
460
+ const line = (await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal)).subarray(0, -2).toString("ascii");
461
+ const size = parseChunkSize(line);
462
+ if (size === 0) {
463
+ while (true) {
464
+ const trailer = await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal);
465
+ if (trailer.equals(CRLF)) break;
466
+ }
467
+ return null;
468
+ }
469
+ chunkRemaining = size;
470
+ }
471
+ const value = await reader.read(Math.min(chunkRemaining, MAX_BODY_SLICE_BYTES), signal);
472
+ chunkRemaining -= value.byteLength;
473
+ if (chunkRemaining === 0) {
474
+ const ending = await reader.read(2, signal);
475
+ if (!ending.equals(CRLF)) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk terminator");
476
+ }
477
+ return value;
478
+ }
479
+ if (remaining !== undefined) {
480
+ if (remaining === 0) return null;
481
+ const value = await reader.read(Math.min(remaining, MAX_BODY_SLICE_BYTES), signal);
482
+ remaining -= value.byteLength;
483
+ return value;
484
+ }
485
+ const value = await reader.readAny(signal);
486
+ return value.byteLength === 0 ? null : value;
487
+ };
488
+ return new ReadableStream<Uint8Array>({
489
+ async pull(controller) {
490
+ try {
491
+ const value = await readChunk();
492
+ if (value === null) {
493
+ finish();
494
+ controller.close();
495
+ return;
496
+ }
497
+ controller.enqueue(value);
498
+ } catch (error) {
499
+ finish();
500
+ controller.error(error);
501
+ socket.destroy();
502
+ }
503
+ },
504
+ cancel() {
505
+ finish();
506
+ socket.destroy();
507
+ },
508
+ });
509
+ }
510
+
511
+ /** Statuses and methods that carry no response body, whatever the headers say about one. */
512
+ function bodylessResponse(method: string, status: number): boolean {
513
+ return method === "HEAD" || status === 204 || status === 304 || (status >= 100 && status < 200);
514
+ }
515
+
516
+ /**
517
+ * Undo the content-coding this transport has to undo itself.
518
+ *
519
+ * `fetch` decodes content-codings below the Response constructor. This one assembles the body
520
+ * from a raw socket, so `new Response(body, { headers })` hands the compressed bytes straight
521
+ * to whatever reads them: `response.json()` throws SyntaxError on the gzip magic number and an
522
+ * SSE reader sees noise instead of frames. The request asks for `identity`, so a coded
523
+ * response means an upstream ignored that; gzip and deflate are undone here, and any other
524
+ * coding fails closed rather than surfacing bytes no caller can parse.
525
+ *
526
+ * No decompressed-size ceiling is imposed. The identity path has no total-size bound either —
527
+ * it cannot, because a long-lived SSE stream is legitimately unbounded — and a ceiling on only
528
+ * the coded path would fail responses that succeed uncompressed.
529
+ */
530
+ function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
531
+ const coding = (headers.get("content-encoding") ?? "").trim().toLowerCase();
532
+ if (coding === "" || coding === "identity") return undefined;
533
+ if (coding === "gzip" || coding === "x-gzip") return "gzip";
534
+ if (coding === "deflate") return "deflate";
535
+ throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding);
536
+ }
537
+
538
+ export async function socks5Fetch(
539
+ input: RequestInfo | URL,
540
+ init: RequestInit | undefined,
541
+ proxy: string,
542
+ ): Promise<Response> {
543
+ const protocol = (init as (RequestInit & { protocol?: string }) | undefined)?.protocol;
544
+ if (protocol === "http2" || protocol === "h2") {
545
+ throw new Socks5FetchError("SOCKS5 transport cannot honor an explicit HTTP/2 pin");
546
+ }
547
+ const request = new Request(input, init);
548
+ const target = new URL(request.url);
549
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
550
+ throw new Socks5FetchError(`SOCKS5 fetch only supports HTTP(S) URLs, got ${target.protocol}`);
551
+ }
552
+ const tunnel = await socks5Connect(proxy, target, request.signal);
553
+ if (request.signal.aborted) {
554
+ tunnel.destroy();
555
+ throw request.signal.reason ?? new Error("The operation was aborted");
556
+ }
557
+ let socket: Socket = tunnel;
558
+ let reader: SocketReader | undefined;
559
+ // Pending reads carry the abort reason; destruction must not enqueue an
560
+ // unhandled socket error after their listeners have been removed.
561
+ const onAbort = () => socket.destroy();
562
+ request.signal.addEventListener("abort", onAbort, { once: true });
563
+ try {
564
+ if (target.protocol === "https:") socket = await secureSocket(tunnel, target, request.signal);
565
+ socket.setTimeout(SOCKS5_RESPONSE_TIMEOUT_MS, () => {
566
+ socket.destroy(new Socks5FetchError("SOCKS5 upstream request timed out"));
567
+ });
568
+ // Observe socket errors and early responses while uploading, not only after it.
569
+ reader = new SocketReader(socket);
570
+ const headers = requestHeaders(request, target);
571
+ const head = `${request.method} ${target.pathname}${target.search} HTTP/1.1\r\n${headers.text}\r\n`;
572
+ socket.write(head);
573
+ if (request.body) {
574
+ const bodyReader = request.body.getReader();
575
+ try {
576
+ while (true) {
577
+ const next = await bodyReader.read();
578
+ if (next.done) break;
579
+ const body = headers.chunked
580
+ ? Buffer.concat([Buffer.from(`${next.value.byteLength.toString(16)}\r\n`), Buffer.from(next.value), CRLF])
581
+ : next.value;
582
+ if (!socket.write(body)) await waitForDrain(socket, request.signal);
583
+ }
584
+ if (headers.chunked && !socket.write("0\r\n\r\n")) await waitForDrain(socket, request.signal);
585
+ } finally {
586
+ bodyReader.releaseLock();
587
+ }
588
+ }
589
+ let responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal));
590
+ while (responseHead.status >= 100 && responseHead.status < 200 && responseHead.status !== 101) {
591
+ responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal));
592
+ }
593
+ // A bodyless response has no coded bytes to undo, so its `content-encoding` describes the
594
+ // representation it would have sent and must neither be decoded nor refused — a HEAD whose
595
+ // peer advertises brotli is a correct answer, not an unreadable body. For everything else,
596
+ // reject a coding this transport cannot undo before the body stream takes the socket, so the
597
+ // failure path is the ordinary one below rather than an orphaned reader.
598
+ const codingFormat = bodylessResponse(request.method, responseHead.status)
599
+ ? undefined
600
+ : contentCodingFormat(responseHead.headers);
601
+ const body = responseBody(reader, socket, request.signal, responseHead.headers, responseHead.status, request.method);
602
+ const responseHeaders = new Headers(responseHead.headers);
603
+ if (codingFormat !== undefined) {
604
+ responseHeaders.delete("content-encoding");
605
+ // The declared length describes the coded bytes, not what the caller now reads.
606
+ responseHeaders.delete("content-length");
607
+ }
608
+ // `DecompressionStream` declares its writable side as `WritableStream<BufferSource>`, and
609
+ // TypeScript measures `WritableStream` as invariant in its chunk type, so the pair is not
610
+ // assignable to `ReadableWritablePair<Uint8Array, Uint8Array>` even though every chunk this
611
+ // body produces is a valid `BufferSource`. The conversion states that relationship and
612
+ // nothing else; it does not widen what is actually written.
613
+ const decompressor = codingFormat === undefined
614
+ ? undefined
615
+ : new DecompressionStream(codingFormat) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
616
+ const decodedBody = body !== null && decompressor !== undefined
617
+ ? body.pipeThrough(decompressor)
618
+ : body;
619
+ request.signal.removeEventListener("abort", onAbort);
620
+ return new Response(decodedBody, {
621
+ status: responseHead.status,
622
+ statusText: responseHead.statusText,
623
+ headers: responseHeaders,
624
+ });
625
+ } catch (error) {
626
+ request.signal.removeEventListener("abort", onAbort);
627
+ reader?.dispose();
628
+ socket.destroy();
629
+ throw error;
630
+ }
631
+ }