@bitkyc08/opencodex 2.58.0 → 2.60.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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  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 +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -0,0 +1,741 @@
1
+ import net, { type Socket } from "node:net";
2
+ import tls, { type TLSSocket } from "node:tls";
3
+ import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics";
4
+
5
+ const DEFAULT_SOCKS5_PORT = 1080;
6
+ const SOCKS5_CONNECT_TIMEOUT_MS = 30_000;
7
+ const SOCKS5_RESPONSE_TIMEOUT_MS = 200_000;
8
+ const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
9
+ const MAX_BODY_SLICE_BYTES = 64 * 1024;
10
+ const SOCKS5_VERSION = 0x05;
11
+ const SOCKS5_NO_AUTH = 0x00;
12
+ const SOCKS5_USER_PASS = 0x02;
13
+ const SOCKS5_CONNECT = 0x01;
14
+ const SOCKS5_DOMAIN = 0x03;
15
+ const SOCKS5_SUCCESS = 0x00;
16
+ const CRLF = Buffer.from("\r\n");
17
+ const HEADER_END = Buffer.from("\r\n\r\n");
18
+
19
+ export class Socks5FetchError extends Error {
20
+ override readonly name = "Socks5FetchError";
21
+ }
22
+
23
+ function proxyCredentials(proxy: URL): { username?: Uint8Array; password?: Uint8Array } {
24
+ if (!proxy.username && !proxy.password) return {};
25
+ let username: string;
26
+ let password: string;
27
+ try {
28
+ username = decodeURIComponent(proxy.username);
29
+ password = decodeURIComponent(proxy.password);
30
+ } catch {
31
+ throw new Socks5FetchError("SOCKS5 proxy credentials contain invalid percent encoding");
32
+ }
33
+ const usernameBytes = new TextEncoder().encode(username);
34
+ const passwordBytes = new TextEncoder().encode(password);
35
+ if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) {
36
+ throw new Socks5FetchError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes");
37
+ }
38
+ return { username: usernameBytes, password: passwordBytes };
39
+ }
40
+
41
+ function validateProxy(proxy: string): URL {
42
+ let parsed: URL;
43
+ try {
44
+ parsed = new URL(proxy);
45
+ } catch {
46
+ throw new Socks5FetchError("SOCKS5 proxy URL is invalid");
47
+ }
48
+ if (parsed.protocol !== "socks5:" && parsed.protocol !== "socks5h:") {
49
+ throw new Socks5FetchError(`unsupported SOCKS5 proxy protocol: ${parsed.protocol}`);
50
+ }
51
+ if (!parsed.hostname) throw new Socks5FetchError("SOCKS5 proxy URL has no host");
52
+ if (parsed.port && (!/^\d+$/.test(parsed.port) || Number(parsed.port) < 1 || Number(parsed.port) > 65535)) {
53
+ throw new Socks5FetchError("SOCKS5 proxy port is invalid");
54
+ }
55
+ if (parsed.search || parsed.hash) throw new Socks5FetchError("SOCKS5 proxy URL must not contain a query or fragment");
56
+ proxyCredentials(parsed);
57
+ return parsed;
58
+ }
59
+
60
+ function targetPort(target: URL): number {
61
+ if (target.port) return Number(target.port);
62
+ return target.protocol === "https:" ? 443 : 80;
63
+ }
64
+
65
+ function connectSocket(hostname: string, port: number, signal?: AbortSignal): Promise<Socket> {
66
+ return new Promise((resolve, reject) => {
67
+ if (signal?.aborted) {
68
+ reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
69
+ return;
70
+ }
71
+ const socket = net.createConnection({ host: hostname, port });
72
+ let settled = false;
73
+ let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
74
+ fail(new Socks5FetchError("SOCKS5 proxy connection timed out"));
75
+ }, SOCKS5_CONNECT_TIMEOUT_MS);
76
+ const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
77
+ const onConnect = () => {
78
+ if (settled) return;
79
+ settled = true;
80
+ if (timer !== undefined) clearTimeout(timer);
81
+ timer = undefined;
82
+ cleanup();
83
+ resolve(socket);
84
+ };
85
+ const onError = (error: Error) => fail(error);
86
+ const onClose = () => fail(new Socks5FetchError("SOCKS5 proxy closed before connecting"));
87
+ const cleanup = () => {
88
+ signal?.removeEventListener("abort", onAbort);
89
+ socket.removeListener("connect", onConnect);
90
+ socket.removeListener("error", onError);
91
+ socket.removeListener("close", onClose);
92
+ };
93
+ const fail = (error: unknown) => {
94
+ if (settled) return;
95
+ settled = true;
96
+ if (timer !== undefined) clearTimeout(timer);
97
+ timer = undefined;
98
+ cleanup();
99
+ socket.destroy();
100
+ reject(error instanceof Error ? error : new Error(String(error)));
101
+ };
102
+ socket.once("connect", onConnect);
103
+ socket.once("error", onError);
104
+ socket.once("close", onClose);
105
+ signal?.addEventListener("abort", onAbort, { once: true });
106
+ });
107
+ }
108
+
109
+ class SocketReader {
110
+ private buffer = Buffer.alloc(0);
111
+ private ended = false;
112
+ private failure: Error | undefined;
113
+ private readonly exactWaiters: Array<{
114
+ length: number;
115
+ resolve: (value: Buffer) => void;
116
+ reject: (error: unknown) => void;
117
+ }> = [];
118
+ private readonly anyWaiters: Array<{
119
+ resolve: (value: Buffer) => void;
120
+ reject: (error: unknown) => void;
121
+ }> = [];
122
+
123
+ constructor(private readonly socket: Socket) {
124
+ socket.on("data", this.onData);
125
+ socket.once("error", this.onError);
126
+ socket.once("end", this.onEnd);
127
+ socket.once("close", this.onEnd);
128
+ socket.resume();
129
+ }
130
+
131
+ private readonly onData = (chunk: Buffer | string): void => {
132
+ const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
133
+ if (this.anyWaiters.length > 0) {
134
+ this.anyWaiters.shift()!.resolve(value);
135
+ return;
136
+ }
137
+ this.buffer = Buffer.concat([this.buffer, value]);
138
+ this.flushExact();
139
+ if (this.buffer.byteLength >= MAX_BODY_SLICE_BYTES) this.socket.pause();
140
+ };
141
+
142
+ private readonly onError = (error: Error): void => {
143
+ this.failure = error;
144
+ this.ended = true;
145
+ this.rejectExact(error);
146
+ this.rejectAny(error);
147
+ };
148
+
149
+ private readonly onEnd = (): void => {
150
+ this.ended = true;
151
+ this.rejectExact(this.failure ?? new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived"));
152
+ if (this.failure) this.rejectAny(this.failure);
153
+ else while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.resolve(Buffer.alloc(0));
154
+ };
155
+
156
+ private flushExact(): void {
157
+ while (this.exactWaiters.length > 0 && this.buffer.byteLength >= this.exactWaiters[0]!.length) {
158
+ const waiter = this.exactWaiters.shift()!;
159
+ const value = this.buffer.subarray(0, waiter.length);
160
+ this.buffer = this.buffer.subarray(waiter.length);
161
+ waiter.resolve(value);
162
+ }
163
+ }
164
+
165
+ private rejectExact(error: unknown): void {
166
+ while (this.exactWaiters.length > 0) this.exactWaiters.shift()!.reject(error);
167
+ }
168
+
169
+ private rejectAny(error: unknown): void {
170
+ while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.reject(error);
171
+ }
172
+
173
+ read(length: number, signal?: AbortSignal): Promise<Buffer> {
174
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("The operation was aborted"));
175
+ if (this.buffer.byteLength >= length) {
176
+ const value = this.buffer.subarray(0, length);
177
+ this.buffer = this.buffer.subarray(length);
178
+ if (this.buffer.byteLength < MAX_BODY_SLICE_BYTES) this.socket.resume();
179
+ return Promise.resolve(value);
180
+ }
181
+ if (this.ended) return Promise.reject(this.failure ?? new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived"));
182
+ return new Promise((resolve, reject) => {
183
+ const onAbort = () => {
184
+ const index = this.exactWaiters.indexOf(waiter);
185
+ if (index >= 0) this.exactWaiters.splice(index, 1);
186
+ waiter.reject(signal?.reason ?? new Error("The operation was aborted"));
187
+ };
188
+ // Store this exact object so cancellation removes the queued waiter.
189
+ const waiter = {
190
+ length,
191
+ resolve: (value: Buffer) => { signal?.removeEventListener("abort", onAbort); resolve(value); },
192
+ reject: (error: unknown) => { signal?.removeEventListener("abort", onAbort); reject(error); },
193
+ };
194
+ this.exactWaiters.push(waiter);
195
+ signal?.addEventListener("abort", onAbort, { once: true });
196
+ if (signal?.aborted) onAbort();
197
+ else this.socket.resume();
198
+ });
199
+ }
200
+
201
+ async readUntil(delimiter: Buffer, maxBytes: number, signal?: AbortSignal): Promise<Buffer> {
202
+ // Keep partial framing outside the shared buffer: readAny must wait for NEW
203
+ // bytes instead of returning the same unterminated prefix in a microtask loop.
204
+ let pending = Buffer.alloc(0);
205
+ for (;;) {
206
+ const chunk = await this.readAny(signal);
207
+ if (chunk.byteLength === 0) throw new Socks5FetchError("SOCKS5 upstream closed before response headers");
208
+ pending = Buffer.concat([pending, chunk]);
209
+ const index = pending.indexOf(delimiter);
210
+ if (index >= 0) {
211
+ const end = index + delimiter.byteLength;
212
+ if (end > maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large");
213
+ // Another data event may have arrived while the read promise resumed.
214
+ this.buffer = Buffer.concat([pending.subarray(end), this.buffer]);
215
+ if (this.buffer.byteLength >= MAX_BODY_SLICE_BYTES) this.socket.pause();
216
+ return pending.subarray(0, end);
217
+ }
218
+ if (pending.byteLength >= maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large");
219
+ }
220
+ }
221
+
222
+ readAny(signal?: AbortSignal): Promise<Buffer> {
223
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("The operation was aborted"));
224
+ if (this.buffer.byteLength > 0) {
225
+ const value = this.buffer;
226
+ this.buffer = Buffer.alloc(0);
227
+ this.socket.resume();
228
+ return Promise.resolve(value);
229
+ }
230
+ if (this.failure) return Promise.reject(this.failure);
231
+ if (this.ended) return Promise.resolve(Buffer.alloc(0));
232
+ return new Promise((resolve, reject) => {
233
+ const onAbort = () => {
234
+ const index = this.anyWaiters.indexOf(waiter);
235
+ if (index >= 0) this.anyWaiters.splice(index, 1);
236
+ waiter.reject(signal?.reason ?? new Error("The operation was aborted"));
237
+ };
238
+ const waiter = {
239
+ resolve: (value: Buffer) => { signal?.removeEventListener("abort", onAbort); resolve(value); },
240
+ reject: (error: unknown) => { signal?.removeEventListener("abort", onAbort); reject(error); },
241
+ };
242
+ this.anyWaiters.push(waiter);
243
+ signal?.addEventListener("abort", onAbort, { once: true });
244
+ if (signal?.aborted) onAbort();
245
+ else this.socket.resume();
246
+ });
247
+ }
248
+
249
+ dispose(): void {
250
+ this.socket.pause();
251
+ this.socket.removeListener("data", this.onData);
252
+ this.socket.removeListener("error", this.onError);
253
+ this.socket.removeListener("end", this.onEnd);
254
+ this.socket.removeListener("close", this.onEnd);
255
+ if (this.buffer.byteLength > 0) this.socket.unshift(this.buffer);
256
+ this.buffer = Buffer.alloc(0);
257
+ this.rejectExact(new Socks5FetchError("SOCKS5 reader disposed"));
258
+ this.rejectAny(new Socks5FetchError("SOCKS5 reader disposed"));
259
+ }
260
+ }
261
+
262
+ async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise<Socket> {
263
+ const parsedProxy = validateProxy(proxy);
264
+ const credentials = proxyCredentials(parsedProxy);
265
+ const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, "");
266
+ const socket = await connectSocket(proxyHost, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal);
267
+ socket.setTimeout(SOCKS5_CONNECT_TIMEOUT_MS, () => {
268
+ socket.destroy(new Socks5FetchError("SOCKS5 handshake timed out"));
269
+ });
270
+ const reader = new SocketReader(socket);
271
+ try {
272
+ const methods = credentials.username ? Buffer.from([SOCKS5_NO_AUTH, SOCKS5_USER_PASS]) : Buffer.from([SOCKS5_NO_AUTH]);
273
+ socket.write(Buffer.from([SOCKS5_VERSION, methods.byteLength, ...methods]));
274
+ const greeting = await reader.read(2, signal);
275
+ if (greeting[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid greeting");
276
+ if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) {
277
+ socket.write(Buffer.from([
278
+ 0x01,
279
+ credentials.username.byteLength,
280
+ ...credentials.username,
281
+ credentials.password.byteLength,
282
+ ...credentials.password,
283
+ ]));
284
+ const auth = await reader.read(2, signal);
285
+ if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5FetchError("SOCKS5 proxy authentication failed");
286
+ } else if (greeting[1] !== SOCKS5_NO_AUTH) {
287
+ throw new Socks5FetchError("SOCKS5 proxy does not accept an offered authentication method");
288
+ }
289
+
290
+ const hostname = new TextEncoder().encode(target.hostname);
291
+ if (hostname.byteLength > 255) throw new Socks5FetchError("SOCKS5 target hostname is too long");
292
+ const port = targetPort(target);
293
+ socket.write(Buffer.from([
294
+ SOCKS5_VERSION,
295
+ SOCKS5_CONNECT,
296
+ 0x00,
297
+ SOCKS5_DOMAIN,
298
+ hostname.byteLength,
299
+ ...hostname,
300
+ port >> 8,
301
+ port & 0xff,
302
+ ]));
303
+ const reply = await reader.read(4, signal);
304
+ if (reply[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid connect response");
305
+ if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5FetchError(`SOCKS5 proxy refused the connection (code ${reply[1]})`);
306
+ if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) {
307
+ throw new Socks5FetchError("SOCKS5 proxy returned an invalid address type or reserved byte");
308
+ }
309
+ const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.read(1, signal))[0]! : 16;
310
+ await reader.read(addressLength + 2, signal);
311
+ socket.setTimeout(0);
312
+ return socket;
313
+ } catch (error) {
314
+ socket.destroy();
315
+ throw error;
316
+ } finally {
317
+ reader.dispose();
318
+ }
319
+ }
320
+
321
+ function requestHeaders(request: Request, target: URL): { text: string; chunked: boolean } {
322
+ const headers = new Headers(request.headers);
323
+ if (!headers.has("host")) headers.set("host", target.host);
324
+ if (!headers.has("connection")) headers.set("connection", "close");
325
+ // This transport assembles the body itself, so a content-coding it did not ask for becomes
326
+ // its own problem to undo. Ask for none by default and leave an explicit caller choice
327
+ // alone: `fetchWithHeaderTimeout` selects identity for some streaming calls and preserves a
328
+ // caller's selection elsewhere, and silently overriding that would make the two disagree.
329
+ if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
330
+ const chunked = request.body !== null
331
+ && !headers.has("content-length")
332
+ && !headers.has("transfer-encoding");
333
+ if (chunked) headers.set("transfer-encoding", "chunked");
334
+ return {
335
+ text: [...headers.entries()].map(([key, value]) => `${key}: ${value}\r\n`).join(""),
336
+ chunked,
337
+ };
338
+ }
339
+
340
+ async function secureSocket(socket: Socket, target: URL, signal: AbortSignal): Promise<TLSSocket> {
341
+ return new Promise((resolve, reject) => {
342
+ const tlsSocket = tls.connect({
343
+ socket,
344
+ servername: target.hostname,
345
+ rejectUnauthorized: true,
346
+ });
347
+ const onAbort = () => {
348
+ tlsSocket.destroy();
349
+ reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
350
+ };
351
+ const onSecureConnect = () => {
352
+ signal.removeEventListener("abort", onAbort);
353
+ resolve(tlsSocket);
354
+ };
355
+ tlsSocket.once("secureConnect", onSecureConnect);
356
+ tlsSocket.once("error", error => {
357
+ signal.removeEventListener("abort", onAbort);
358
+ reject(error);
359
+ });
360
+ signal.addEventListener("abort", onAbort, { once: true });
361
+ });
362
+ }
363
+
364
+ function parseChunkSize(line: string): number {
365
+ const token = line.split(";", 1)[0]!.trim();
366
+ if (!/^[0-9a-fA-F]+$/.test(token)) {
367
+ throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk size");
368
+ }
369
+ const size = Number.parseInt(token, 16);
370
+ if (!Number.isSafeInteger(size) || size < 0) {
371
+ throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk size");
372
+ }
373
+ return size;
374
+ }
375
+
376
+ function parseResponseHead(raw: Buffer): { status: number; statusText: string; headers: Headers } {
377
+ const text = raw.toString("latin1");
378
+ const lines = text.split("\r\n");
379
+ const statusLine = lines.shift() ?? "";
380
+ const match = /^HTTP\/\d(?:\.\d)?\s+(\d{3})(?:\s+(.*))?$/.exec(statusLine);
381
+ if (!match) throw new Socks5FetchError("SOCKS5 upstream returned an invalid HTTP response");
382
+ const headers = new Headers();
383
+ for (const line of lines) {
384
+ if (!line) continue;
385
+ const separator = line.indexOf(":");
386
+ if (separator <= 0) throw new Socks5FetchError("SOCKS5 upstream returned an invalid HTTP header");
387
+ headers.append(line.slice(0, separator), line.slice(separator + 1).trim());
388
+ }
389
+ return { status: Number(match[1]), statusText: match[2] ?? "", headers };
390
+ }
391
+
392
+ function waitForDrain(socket: Socket, signal: AbortSignal): Promise<void> {
393
+ if (socket.destroyed) return Promise.reject(new Socks5FetchError("SOCKS5 socket closed while sending the request"));
394
+ if (signal.aborted) {
395
+ return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
396
+ }
397
+ return new Promise((resolve, reject) => {
398
+ let settled = false;
399
+ const cleanup = () => {
400
+ socket.removeListener("drain", onDrain);
401
+ socket.removeListener("error", onError);
402
+ socket.removeListener("close", onClose);
403
+ signal.removeEventListener("abort", onAbort);
404
+ };
405
+ const finish = (error?: unknown) => {
406
+ if (settled) return;
407
+ settled = true;
408
+ cleanup();
409
+ if (error === undefined) resolve();
410
+ else reject(error instanceof Error ? error : new Error(String(error)));
411
+ };
412
+ const onDrain = () => finish();
413
+ const onError = (error: Error) => finish(error);
414
+ const onClose = () => finish(new Socks5FetchError("SOCKS5 socket closed while sending the request"));
415
+ const onAbort = () => finish(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
416
+ socket.once("drain", onDrain);
417
+ socket.once("error", onError);
418
+ socket.once("close", onClose);
419
+ signal.addEventListener("abort", onAbort, { once: true });
420
+ if (socket.destroyed) onClose();
421
+ else if (signal.aborted) onAbort();
422
+ });
423
+ }
424
+
425
+ function responseBody(
426
+ reader: SocketReader,
427
+ socket: Socket,
428
+ signal: AbortSignal,
429
+ headers: Headers,
430
+ status: number,
431
+ method: string,
432
+ ): ReadableStream<Uint8Array> | null {
433
+ if (bodylessResponse(method, status)) {
434
+ reader.dispose();
435
+ socket.setTimeout(0);
436
+ socket.destroy();
437
+ return null;
438
+ }
439
+ const lengthHeader = headers.get("content-length");
440
+ const contentLength = lengthHeader === null ? undefined : Number(lengthHeader);
441
+ if (contentLength !== undefined && (!Number.isSafeInteger(contentLength) || contentLength < 0)) {
442
+ throw new Socks5FetchError("SOCKS5 upstream returned an invalid content-length");
443
+ }
444
+ const chunked = (headers.get("transfer-encoding") ?? "").toLowerCase().split(",").some(value => value.trim() === "chunked");
445
+ let remaining = contentLength;
446
+ let chunkRemaining = 0;
447
+ let complete = false;
448
+ const finish = () => {
449
+ if (complete) return;
450
+ complete = true;
451
+ signal.removeEventListener("abort", onAbort);
452
+ reader.dispose();
453
+ socket.setTimeout(0);
454
+ socket.destroy();
455
+ };
456
+ const onAbort = () => socket.destroy();
457
+ signal.addEventListener("abort", onAbort, { once: true });
458
+ const readChunk = async (): Promise<Buffer | null> => {
459
+ if (chunked) {
460
+ if (chunkRemaining === 0) {
461
+ const line = (await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal)).subarray(0, -2).toString("ascii");
462
+ const size = parseChunkSize(line);
463
+ if (size === 0) {
464
+ while (true) {
465
+ const trailer = await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal);
466
+ if (trailer.equals(CRLF)) break;
467
+ }
468
+ return null;
469
+ }
470
+ chunkRemaining = size;
471
+ }
472
+ const value = await reader.read(Math.min(chunkRemaining, MAX_BODY_SLICE_BYTES), signal);
473
+ chunkRemaining -= value.byteLength;
474
+ if (chunkRemaining === 0) {
475
+ const ending = await reader.read(2, signal);
476
+ if (!ending.equals(CRLF)) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk terminator");
477
+ }
478
+ return value;
479
+ }
480
+ if (remaining !== undefined) {
481
+ if (remaining === 0) return null;
482
+ const value = await reader.read(Math.min(remaining, MAX_BODY_SLICE_BYTES), signal);
483
+ remaining -= value.byteLength;
484
+ return value;
485
+ }
486
+ const value = await reader.readAny(signal);
487
+ return value.byteLength === 0 ? null : value;
488
+ };
489
+ return new ReadableStream<Uint8Array>({
490
+ async pull(controller) {
491
+ try {
492
+ const value = await readChunk();
493
+ if (value === null) {
494
+ finish();
495
+ controller.close();
496
+ return;
497
+ }
498
+ controller.enqueue(value);
499
+ } catch (error) {
500
+ finish();
501
+ controller.error(error);
502
+ socket.destroy();
503
+ }
504
+ },
505
+ cancel() {
506
+ finish();
507
+ socket.destroy();
508
+ },
509
+ });
510
+ }
511
+
512
+ /** Statuses and methods that carry no response body, whatever the headers say about one. */
513
+ function bodylessResponse(method: string, status: number): boolean {
514
+ // 205 belongs here for the same reason 204 does, and leaving it out was not a judgement call:
515
+ // a peer that resets the caller's view sends no body, so the Response constructor rejects one.
516
+ return method === "HEAD" || isNullBodyStatus(status) || (status >= 100 && status < 200);
517
+ }
518
+
519
+ /**
520
+ * Undo the content-coding this transport has to undo itself.
521
+ *
522
+ * `fetch` decodes content-codings below the Response constructor. This one assembles the body
523
+ * from a raw socket, so `new Response(body, { headers })` hands the compressed bytes straight
524
+ * to whatever reads them: `response.json()` throws SyntaxError on the gzip magic number and an
525
+ * SSE reader sees noise instead of frames. The request asks for `identity`, so a coded
526
+ * response means an upstream ignored that; gzip and deflate are undone here, and any other
527
+ * coding fails closed rather than surfacing bytes no caller can parse.
528
+ *
529
+ * No decompressed-size ceiling is imposed. The identity path has no total-size bound either —
530
+ * it cannot, because a long-lived SSE stream is legitimately unbounded — and a ceiling on only
531
+ * the coded path would fail responses that succeed uncompressed.
532
+ */
533
+ function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
534
+ const coding = classifyContentCoding(headers);
535
+ if (coding.kind === "identity") return undefined;
536
+ if (coding.kind === "decodable") return coding.format;
537
+ throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding.coding);
538
+ }
539
+
540
+ /** Read response heads until the final one, consuming the interim informational answers. */
541
+ async function finalResponseHead(
542
+ reader: SocketReader,
543
+ signal: AbortSignal,
544
+ ): Promise<{ status: number; statusText: string; headers: Headers }> {
545
+ let head = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, signal));
546
+ while (head.status >= 100 && head.status < 200 && head.status !== 101) {
547
+ head = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, signal));
548
+ }
549
+ return head;
550
+ }
551
+
552
+ /**
553
+ * A promise that rejects with the caller's abort reason, and a way to stop listening.
554
+ *
555
+ * Destroying the socket is not enough to end a pending `bodyReader.read()`: that promise belongs
556
+ * to the caller's body stream, which knows nothing about this socket. Racing it against this one
557
+ * is what turns an abort into a settled fetch instead of a permanently pending one.
558
+ */
559
+ function abortRejection(signal: AbortSignal): { promise: Promise<never>; dispose: () => void } {
560
+ let onAbort = (): void => { /* replaced below */ };
561
+ const promise = new Promise<never>((_resolve, reject) => {
562
+ // Reject with the caller's reason exactly, including `null` and `false`. A real
563
+ // `AbortSignal` always has one — `abort()` with no argument supplies an AbortError — so
564
+ // the fallback covers only a hand-built signal that exposes none.
565
+ onAbort = () => reject(signal.reason === undefined ? new Error("The operation was aborted") : signal.reason);
566
+ if (signal.aborted) onAbort();
567
+ else signal.addEventListener("abort", onAbort, { once: true });
568
+ });
569
+ // The race below may settle on another branch first; this rejection must not be reported as
570
+ // unhandled when nobody is left waiting for it.
571
+ promise.catch(() => { /* surfaced by whoever loses the race */ });
572
+ return { promise, dispose: () => signal.removeEventListener("abort", onAbort) };
573
+ }
574
+
575
+ /**
576
+ * The two races below have different possible outcomes, so they have different result types.
577
+ * One union covering both would let the compiler accept reading `value` off a drain result,
578
+ * which is the kind of mistake a type is supposed to catch rather than describe.
579
+ */
580
+ type Answered = { kind: "answered" };
581
+ type BodyStep = { kind: "chunk"; value: Uint8Array } | { kind: "end" } | Answered;
582
+ type DrainStep = { kind: "drained" } | Answered;
583
+
584
+ /**
585
+ * Send the request body, settling on caller abort or on an answer that arrives first.
586
+ *
587
+ * A peer is entitled to answer a request it has not finished receiving — a 413 or a 401 lands
588
+ * while a large body is still going out — and a body stream is entitled to stall. Waiting for
589
+ * the upload to finish before looking at the socket made those two facts combine into a fetch
590
+ * that never settles, with the answer already sitting in the receive buffer.
591
+ *
592
+ * `answered` covers the head settling either way: a final response means the peer has stopped
593
+ * reading, and a terminal socket failure means nothing more can be written. Which of the two it
594
+ * was belongs to whoever awaits the head, so this only needs to know that it happened.
595
+ */
596
+ async function uploadRequestBody(
597
+ socket: Socket,
598
+ request: Request,
599
+ chunked: boolean,
600
+ head: Promise<unknown>,
601
+ ): Promise<void> {
602
+ const body = request.body;
603
+ if (!body) return;
604
+ const signal = request.signal;
605
+ const bodyReader = body.getReader();
606
+ const abort = abortRejection(signal);
607
+ const answered: Promise<Answered> = head.then(
608
+ (): Answered => ({ kind: "answered" }),
609
+ (): Answered => ({ kind: "answered" }),
610
+ );
611
+ let completed = false;
612
+ try {
613
+ for (;;) {
614
+ const read: Promise<BodyStep> = bodyReader.read().then(
615
+ (result): BodyStep => (result.done ? { kind: "end" } : { kind: "chunk", value: result.value }),
616
+ );
617
+ // Releasing the lock below rejects a read still waiting on a source that stopped
618
+ // producing. That rejection is this function's own doing, not a failure to report.
619
+ read.catch(() => { /* settled by releasing the reader */ });
620
+ const step = await Promise.race([read, answered, abort.promise]);
621
+ if (step.kind === "answered") break;
622
+ if (step.kind === "end") {
623
+ completed = true;
624
+ break;
625
+ }
626
+ const payload = chunked
627
+ ? Buffer.concat([Buffer.from(`${step.value.byteLength.toString(16)}\r\n`), Buffer.from(step.value), CRLF])
628
+ : step.value;
629
+ if (socket.write(payload)) continue;
630
+ const drain: Promise<DrainStep> = waitForDrain(socket, signal).then((): DrainStep => ({ kind: "drained" }));
631
+ if ((await Promise.race([drain, answered, abort.promise])).kind === "answered") break;
632
+ }
633
+ // A terminating chunk written after the peer has already answered goes into a conversation
634
+ // that is over, and a peer that has moved on may read it as the head of the next request.
635
+ if (completed && chunked && !socket.write("0\r\n\r\n")) {
636
+ const drain: Promise<DrainStep> = waitForDrain(socket, signal).then((): DrainStep => ({ kind: "drained" }));
637
+ await Promise.race([drain, answered, abort.promise]);
638
+ }
639
+ } finally {
640
+ abort.dispose();
641
+ if (!completed) {
642
+ // Cancelling runs the caller's own cancel algorithm, which is free to never settle, so it
643
+ // is started rather than awaited. Releasing the lock is what actually frees a read still
644
+ // waiting on a body that stopped producing.
645
+ void bodyReader.cancel().catch(() => { /* the caller's stream owns this outcome */ });
646
+ }
647
+ try { bodyReader.releaseLock(); } catch { /* already released */ }
648
+ }
649
+ }
650
+
651
+ export async function socks5Fetch(
652
+ input: RequestInfo | URL,
653
+ init: RequestInit | undefined,
654
+ proxy: string,
655
+ ): Promise<Response> {
656
+ const protocol = (init as (RequestInit & { protocol?: string }) | undefined)?.protocol;
657
+ if (protocol === "http2" || protocol === "h2") {
658
+ throw new Socks5FetchError("SOCKS5 transport cannot honor an explicit HTTP/2 pin");
659
+ }
660
+ const request = new Request(input, init);
661
+ const target = new URL(request.url);
662
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
663
+ throw new Socks5FetchError(`SOCKS5 fetch only supports HTTP(S) URLs, got ${target.protocol}`);
664
+ }
665
+ const tunnel = await socks5Connect(proxy, target, request.signal);
666
+ if (request.signal.aborted) {
667
+ tunnel.destroy();
668
+ throw request.signal.reason ?? new Error("The operation was aborted");
669
+ }
670
+ let socket: Socket = tunnel;
671
+ let reader: SocketReader | undefined;
672
+ // Pending reads carry the abort reason; destruction must not enqueue an
673
+ // unhandled socket error after their listeners have been removed.
674
+ const onAbort = () => socket.destroy();
675
+ request.signal.addEventListener("abort", onAbort, { once: true });
676
+ try {
677
+ if (target.protocol === "https:") socket = await secureSocket(tunnel, target, request.signal);
678
+ socket.setTimeout(SOCKS5_RESPONSE_TIMEOUT_MS, () => {
679
+ socket.destroy(new Socks5FetchError("SOCKS5 upstream request timed out"));
680
+ });
681
+ // Observe socket errors and early responses while uploading, not only after it.
682
+ reader = new SocketReader(socket);
683
+ const headers = requestHeaders(request, target);
684
+ const head = `${request.method} ${target.pathname}${target.search} HTTP/1.1\r\n${headers.text}\r\n`;
685
+ socket.write(head);
686
+ // Start reading the answer before the upload finishes, and keep one consumer of the socket
687
+ // reader for the whole exchange. The upload races this promise so an early final response or
688
+ // a terminal socket failure ends it, instead of leaving it waiting on a body chunk that is
689
+ // never coming while the answer sits in the receive buffer.
690
+ const pendingHead = finalResponseHead(reader, request.signal);
691
+ // The upload observes this promise through a handler of its own; this one keeps a rejection
692
+ // from being reported as unhandled in the window before the await below.
693
+ pendingHead.catch(() => { /* rethrown by the await below */ });
694
+ await uploadRequestBody(socket, request, headers.chunked, pendingHead);
695
+ const responseHead = await pendingHead;
696
+ // A bodyless response has no coded bytes to undo, so its `content-encoding` describes the
697
+ // representation it would have sent and must neither be decoded nor refused — a HEAD whose
698
+ // peer advertises brotli is a correct answer, not an unreadable body. For everything else,
699
+ // reject a coding this transport cannot undo before the body stream takes the socket, so the
700
+ // failure path is the ordinary one below rather than an orphaned reader.
701
+ const codingFormat = bodylessResponse(request.method, responseHead.status)
702
+ ? undefined
703
+ : contentCodingFormat(responseHead.headers);
704
+ const body = responseBody(reader, socket, request.signal, responseHead.headers, responseHead.status, request.method);
705
+ const responseHeaders = new Headers(responseHead.headers);
706
+ if (codingFormat !== undefined) {
707
+ responseHeaders.delete("content-encoding");
708
+ // The declared length describes the coded bytes, not what the caller now reads.
709
+ responseHeaders.delete("content-length");
710
+ }
711
+ // `DecompressionStream` declares its writable side as `WritableStream<BufferSource>`, and
712
+ // TypeScript measures `WritableStream` as invariant in its chunk type, so the pair is not
713
+ // assignable to `ReadableWritablePair<Uint8Array, Uint8Array>` even though every chunk this
714
+ // body produces is a valid `BufferSource`. The conversion states that relationship and
715
+ // nothing else; it does not widen what is actually written.
716
+ const decompressor = codingFormat === undefined
717
+ ? undefined
718
+ : new DecompressionStream(codingFormat) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
719
+ const decodedBody = body !== null && decompressor !== undefined
720
+ ? body.pipeThrough(decompressor)
721
+ : body;
722
+ request.signal.removeEventListener("abort", onAbort);
723
+ return new Response(decodedBody, {
724
+ status: responseHead.status,
725
+ statusText: responseHead.statusText,
726
+ headers: responseHeaders,
727
+ });
728
+ } catch (error) {
729
+ request.signal.removeEventListener("abort", onAbort);
730
+ reader?.dispose();
731
+ socket.destroy();
732
+ // An aborted caller gets its own reason back. Several waiters inside this transport can win
733
+ // the race that settles an abort, and they do not agree on what to do with a reason they
734
+ // consider absent: the socket reader substitutes an Error for a null reason, others coerce
735
+ // anything that is not an Error. Without this, the same `abort(null)` surfaces as an Error
736
+ // or as null depending on scheduling. `undefined` is left alone so a hand-built signal
737
+ // still reaches the thrown cause below.
738
+ if (request.signal.aborted && request.signal.reason !== undefined) throw request.signal.reason;
739
+ throw error;
740
+ }
741
+ }