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.
- package/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- 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
|
+
}
|