socketduct 0.1.15

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 (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,366 @@
1
+ import {Agent} from "node:https"
2
+ import {isIP} from "node:net"
3
+ import {checkServerIdentity, connect as tlsConnect} from "node:tls"
4
+
5
+ import {connectSocketduct, kCloseSocketductSession} from "./socketduct-socket.js"
6
+
7
+ const requestContext = Symbol("socketductHttpsRequestContext")
8
+
9
+ /**
10
+ * @typedef {import('./socketduct-socket.js').SocketductSocket} SocketductSocket
11
+ * @typedef {import('./socketduct-socket.js').SocketductTlsOptions} SocketductTlsOptions
12
+ * @typedef {object} SocketductTargetTlsOptions
13
+ * @property {string | Buffer | Array<string | Buffer>} [ca] - Target server CA material.
14
+ * @property {string | Buffer | Array<string | Buffer>} [cert] - Target client certificate material.
15
+ * @property {string | Buffer} [key] - Target client private key material.
16
+ * @property {boolean} [rejectUnauthorized] - Reject an unauthorized target certificate. Defaults to true.
17
+ * @property {string} [servername] - Non-empty target certificate identity and SNI hostname. Defaults to the request or fixed target host; SNI is omitted for IP literals.
18
+ * @typedef {object} SocketductHttpsAgentOptions
19
+ * @property {{host: string, port: number, token: string}} relay - Relay connection options.
20
+ * @property {SocketductTlsOptions} [relayTls] - TLS options for the edge-to-relay connection.
21
+ * @property {{host: string, port: number}} target - Target requested for every HTTPS connection.
22
+ * @property {SocketductTargetTlsOptions} [targetTls] - TLS options for the target HTTPS connection.
23
+ * @property {string} spoolDirectory - Spool directory.
24
+ * @property {{initialDelayMs?: number}} [reconnect] - Relay reconnect options.
25
+ * @property {string} [sessionNamePrefix] - Session name prefix.
26
+ * @property {string} [sessionName] - Fixed session name.
27
+ * @property {boolean} [keepAlive] - Keep sockets alive between HTTPS requests.
28
+ * @property {number} [maxSockets] - Maximum sockets.
29
+ * @property {number} [maxFreeSockets] - Maximum free sockets.
30
+ * @property {number} [maxBytes] - Maximum total spool bytes per session.
31
+ * @property {number} [maxAgeMs] - Maximum age of a spool session before operations are rejected.
32
+ * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
33
+ * @typedef {{aborted: boolean, cancel: ((error: Error) => void) | undefined, ownSocket: (socket: import('node:stream').Duplex) => boolean}} RequestContext
34
+ * @typedef {{cancel: (error: Error, cleanup?: () => void) => void}} PendingConnection
35
+ */
36
+
37
+ /** HTTPS agent whose target TCP transport is a resumable Socketduct session. */
38
+ export class SocketductHttpsAgent extends Agent {
39
+ /** @type {SocketductHttpsAgentOptions} */
40
+ #options
41
+
42
+ /** @type {number} */
43
+ #connectionId = 0
44
+
45
+ /** @type {Set<PendingConnection>} */
46
+ #pending = new Set()
47
+
48
+ /** @type {boolean} */
49
+ #destroyed = false
50
+
51
+ /** @param {SocketductHttpsAgentOptions} options - Agent and tunnel options. */
52
+ constructor(options) {
53
+ if (options.targetTls?.servername !== undefined &&
54
+ (typeof options.targetTls.servername !== "string" || options.targetTls.servername.length === 0)) {
55
+ throw new TypeError("Target TLS servername must be a non-empty string")
56
+ }
57
+ super({
58
+ keepAlive: options.keepAlive,
59
+ maxSockets: options.maxSockets,
60
+ maxFreeSockets: options.maxFreeSockets
61
+ })
62
+ if ((options.targetTls?.cert === undefined) !== (options.targetTls?.key === undefined)) {
63
+ throw new Error("Target TLS cert and key must both be set (atomic pair)")
64
+ }
65
+ this.#options = options
66
+ }
67
+
68
+ /**
69
+ * Associates an asynchronous TLS connection attempt with its originating
70
+ * request so aborts before socket assignment still tear down the tunnel.
71
+ * @param {import('node:http').ClientRequest} req - Originating request.
72
+ * @param {import('node:http').ClientRequestArgs} options - Agent options.
73
+ * @param {(error: Error | null, socket?: import('node:tls').TLSSocket) => void} callback - Agent callback.
74
+ * @returns {void}
75
+ */
76
+ createSocket(req, options, callback) {
77
+ const requestSocketOwner = /** @type {{socket: import('node:stream').Duplex | null}} */ (/** @type {unknown} */ (req))
78
+ /** @type {import('node:stream').Duplex | undefined} */
79
+ let provisionalSocket
80
+ /** @type {RequestContext} */
81
+ const context = {
82
+ aborted: req.destroyed,
83
+ cancel: undefined,
84
+ ownSocket: (socket) => {
85
+ provisionalSocket = socket
86
+ // Node removes options.signal before calling Agent.addRequest(). Its
87
+ // AbortSignal handler still destroys req.socket, so expose only the
88
+ // in-progress ownership socket here. Do not call req.onSocket(): Node
89
+ // keeps HTTP output buffered until the Agent callback after secureConnect.
90
+ requestSocketOwner.socket = socket
91
+ if (!req.destroyed) return true
92
+ context.aborted = true
93
+ context.cancel?.(abortError())
94
+ return false
95
+ }
96
+ }
97
+ const abort = () => {
98
+ context.aborted = true
99
+ context.cancel?.(abortError())
100
+ }
101
+ const onClose = () => {
102
+ if (req.destroyed) abort()
103
+ }
104
+ const cleanup = () => {
105
+ req.off("abort", abort)
106
+ req.off("close", onClose)
107
+ if (requestSocketOwner.socket === provisionalSocket) requestSocketOwner.socket = null
108
+ provisionalSocket = undefined
109
+ context.cancel = undefined
110
+ }
111
+ req.once("abort", abort)
112
+ req.once("close", onClose)
113
+ const baseCreateSocket = /** @type {{createSocket: (req: import('node:http').ClientRequest, options: import('node:http').ClientRequestArgs, callback: (error: Error | null, socket?: import('node:stream').Duplex) => void) => void}} */ (/** @type {unknown} */ (Agent.prototype)).createSocket
114
+ baseCreateSocket.call(this, req, {...options, [requestContext]: context}, (error, socket) => {
115
+ cleanup()
116
+ callback(error, /** @type {import('node:tls').TLSSocket | undefined} */ (socket))
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Creates the raw Socketduct session first, then performs target TLS over it.
122
+ * This asynchronous method requires the callback path used by Node's Agent.
123
+ * Returning undefined is intentional: Node must wait for secureConnect.
124
+ * @override
125
+ * @param {import('node:https').RequestOptions} options - Request connection options.
126
+ * @param {(error: Error | null, socket: import('node:stream').Duplex) => void} callback - Agent completion callback.
127
+ * @returns {undefined}
128
+ */
129
+ createConnection(options, callback) {
130
+ if (typeof callback !== "function") {
131
+ const error = /** @type {TypeError & {code: string}} */ (new TypeError("The callback argument must be a function for SocketductHttpsAgent.createConnection"))
132
+ error.code = "ERR_INVALID_ARG_TYPE"
133
+ throw error
134
+ }
135
+ /**
136
+ * @param {Error | null} error - Completion error.
137
+ * @param {import('node:stream').Duplex} [socket] - Connected TLS socket.
138
+ * @returns {void}
139
+ */
140
+ const done = (error, socket) => {
141
+ callback(error, /** @type {import('node:stream').Duplex} */ (socket))
142
+ }
143
+ this.#connectionId += 1
144
+ const sessionName = this.#options.sessionName
145
+ ?? `${this.#options.sessionNamePrefix ?? "socketduct-https"}-${this.#connectionId}`
146
+ /** @type {Parameters<typeof connectSocketduct>[0]} */
147
+ const socketOptions = {
148
+ relay: this.#options.relay,
149
+ sessionName,
150
+ target: this.#options.target,
151
+ spoolDirectory: this.#options.spoolDirectory
152
+ }
153
+ if (this.#options.reconnect !== undefined) socketOptions.reconnect = this.#options.reconnect
154
+ if (this.#options.relayTls !== undefined) socketOptions.tls = this.#options.relayTls
155
+ if (this.#options.maxBytes !== undefined) socketOptions.maxBytes = this.#options.maxBytes
156
+ if (this.#options.maxAgeMs !== undefined) socketOptions.maxAgeMs = this.#options.maxAgeMs
157
+ if (this.#options.logger !== undefined) socketOptions.logger = this.#options.logger
158
+
159
+ /** @type {SocketductSocket | undefined} */
160
+ let rawSocket
161
+ /** @type {import('node:tls').TLSSocket | undefined} */
162
+ let tlsSocket
163
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
164
+ let timeoutTimer
165
+ let settled = false
166
+
167
+ /** @type {PendingConnection} */
168
+ const pending = {cancel: (error, cleanup) => settle(error, undefined, cleanup)}
169
+ const context = /** @type {import('node:https').RequestOptions & {[requestContext]?: RequestContext}} */ (options)[requestContext]
170
+
171
+ const clearTimeoutTimer = () => {
172
+ if (timeoutTimer !== undefined) {
173
+ clearTimeout(timeoutTimer)
174
+ timeoutTimer = undefined
175
+ }
176
+ }
177
+ const removePreConnectListeners = () => {
178
+ rawSocket?.off("connect", onRawConnect)
179
+ rawSocket?.off("error", onRawError)
180
+ tlsSocket?.off("secureConnect", onSecureConnect)
181
+ tlsSocket?.off("error", onTlsError)
182
+ }
183
+ /**
184
+ * @param {Error | null} error - Connection error or null on success.
185
+ * @param {import('node:tls').TLSSocket} [connectedSocket] - Established target TLS socket.
186
+ * @param {() => void} [cleanup] - Optional pending-agent cleanup callback.
187
+ * @returns {void}
188
+ */
189
+ function settle(error, connectedSocket, cleanup) {
190
+ if (settled) {
191
+ cleanup?.()
192
+ return
193
+ }
194
+ settled = true
195
+ clearTimeoutTimer()
196
+ removePreConnectListeners()
197
+ context && (context.cancel = undefined)
198
+ pendingAgent.#pending.delete(pending)
199
+ if (error !== null) {
200
+ const ignoreLateTlsError = () => {}
201
+ tlsSocket?.on("error", ignoreLateTlsError)
202
+ tlsSocket?.once("close", () => tlsSocket?.off("error", ignoreLateTlsError))
203
+ const finishError = () => {
204
+ tlsSocket?.destroy()
205
+ rawSocket?.destroy()
206
+ done(error)
207
+ cleanup?.()
208
+ }
209
+ if (rawSocket !== undefined && !rawSocket.destroyed) {
210
+ rawSocket.once("error", () => {})
211
+ if (rawSocket.sessionIdentifier !== undefined) {
212
+ void rawSocket[kCloseSocketductSession]().finally(finishError)
213
+ } else {
214
+ finishError()
215
+ }
216
+ } else {
217
+ finishError()
218
+ }
219
+ return
220
+ }
221
+ done(null, connectedSocket)
222
+ cleanup?.()
223
+ }
224
+ const pendingAgent = this
225
+
226
+ const onRawConnect = () => {
227
+ if (settled || rawSocket === undefined) return
228
+ const targetTls = this.#options.targetTls ?? {}
229
+ const requestIdentity = nonEmptyString(options.hostname) ?? nonEmptyString(options.host)
230
+ const certificateIdentity = targetTls.servername ?? requestIdentity ?? this.#options.target.host
231
+ const ipIdentity = isIP(certificateIdentity) !== 0
232
+ /** @type {import('node:tls').ConnectionOptions} */
233
+ const tlsOptions = {
234
+ socket: rawSocket,
235
+ rejectUnauthorized: targetTls.rejectUnauthorized !== false,
236
+ ...(targetTls.ca === undefined ? {} : {ca: targetTls.ca}),
237
+ ...(targetTls.cert === undefined ? {} : {cert: targetTls.cert}),
238
+ ...(targetTls.key === undefined ? {} : {key: targetTls.key})
239
+ }
240
+ if (ipIdentity) {
241
+ tlsOptions.servername = ""
242
+ tlsOptions.checkServerIdentity = (_hostname, certificate) => checkServerIdentity(certificateIdentity, certificate)
243
+ } else {
244
+ tlsOptions.servername = certificateIdentity
245
+ }
246
+ try {
247
+ tlsSocket = tlsConnect(tlsOptions)
248
+ } catch (error) {
249
+ settle(error instanceof Error ? error : new Error(String(error)))
250
+ return
251
+ }
252
+ tlsSocket.once("secureConnect", onSecureConnect)
253
+ tlsSocket.once("error", onTlsError)
254
+ }
255
+ /**
256
+ * @param {Error} error - Raw Socketduct error.
257
+ * @returns {void}
258
+ */
259
+ const onRawError = (error) => settle(error)
260
+ /** @returns {void} - Completes the target TLS handshake. */
261
+ const onSecureConnect = () => {
262
+ if (tlsSocket === undefined || rawSocket === undefined) return
263
+ const connectedTlsSocket = tlsSocket
264
+ const ownedRawSocket = rawSocket
265
+ const closeRawSocket = () => {
266
+ if (!ownedRawSocket.destroyed) ownedRawSocket.destroy()
267
+ }
268
+ const closeTlsSocket = () => {
269
+ if (!connectedTlsSocket.destroyed) connectedTlsSocket.destroy()
270
+ }
271
+ connectedTlsSocket.once("close", closeRawSocket)
272
+ ownedRawSocket.once("close", closeTlsSocket)
273
+ ownedRawSocket.on("error", (error) => {
274
+ if (!connectedTlsSocket.destroyed) connectedTlsSocket.destroy(error)
275
+ })
276
+ settle(null, connectedTlsSocket)
277
+ }
278
+ /**
279
+ * @param {Error} error - Target TLS error.
280
+ * @returns {void}
281
+ */
282
+ const onTlsError = (error) => settle(error)
283
+
284
+ this.#pending.add(pending)
285
+ if (context !== undefined) context.cancel = pending.cancel
286
+ if (this.#destroyed) {
287
+ settle(agentDestroyedError())
288
+ return undefined
289
+ }
290
+ if (context?.aborted) {
291
+ settle(abortError())
292
+ return undefined
293
+ }
294
+ try {
295
+ rawSocket = connectSocketduct(socketOptions)
296
+ } catch (error) {
297
+ settle(error instanceof Error ? error : new Error(String(error)))
298
+ return undefined
299
+ }
300
+ rawSocket.once("connect", onRawConnect)
301
+ rawSocket.once("error", onRawError)
302
+ if (context !== undefined && !context.ownSocket(rawSocket)) return undefined
303
+
304
+ if (typeof options.timeout === "number" && options.timeout > 0) {
305
+ const timeoutMs = options.timeout
306
+ timeoutTimer = setTimeout(() => settle(timeoutError(timeoutMs)), timeoutMs)
307
+ timeoutTimer.unref?.()
308
+ }
309
+ return undefined
310
+ }
311
+
312
+ /**
313
+ * Cancels pending handshakes and destroys established/free TLS sockets.
314
+ * @override
315
+ * @returns {void}
316
+ */
317
+ destroy() {
318
+ if (this.#destroyed) return
319
+ this.#destroyed = true
320
+ const error = agentDestroyedError()
321
+ const pendingConnections = [...this.#pending]
322
+ if (pendingConnections.length === 0) {
323
+ super.destroy()
324
+ return
325
+ }
326
+ let remaining = pendingConnections.length
327
+ const finish = () => {
328
+ remaining -= 1
329
+ if (remaining === 0) super.destroy()
330
+ }
331
+ for (const pending of pendingConnections) pending.cancel(error, finish)
332
+ }
333
+ }
334
+
335
+ /**
336
+ * @param {unknown} value - Candidate certificate identity.
337
+ * @returns {string | undefined} A non-empty identity.
338
+ */
339
+ function nonEmptyString(value) {
340
+ return typeof value === "string" && value.length > 0 ? value : undefined
341
+ }
342
+
343
+ /** @returns {Error & {code: string}} Abort error. */
344
+ function abortError() {
345
+ const error = /** @type {Error & {code: string}} */ (new Error("The operation was aborted"))
346
+ error.name = "AbortError"
347
+ error.code = "ABORT_ERR"
348
+ return error
349
+ }
350
+
351
+ /**
352
+ * @param {number} timeoutMs - Expired target TLS timeout.
353
+ * @returns {Error & {code: string}} Timeout error.
354
+ */
355
+ function timeoutError(timeoutMs) {
356
+ const error = /** @type {Error & {code: string}} */ (new Error(`Target TLS connection timed out after ${timeoutMs}ms`))
357
+ error.code = "ETIMEDOUT"
358
+ return error
359
+ }
360
+
361
+ /** @returns {Error & {code: string}} Agent-destroyed error. */
362
+ function agentDestroyedError() {
363
+ const error = /** @type {Error & {code: string}} */ (new Error("Socketduct HTTPS agent was destroyed"))
364
+ error.code = "ERR_SOCKETDUCT_AGENT_DESTROYED"
365
+ return error
366
+ }