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,1174 @@
|
|
|
1
|
+
import {Duplex} from "node:stream"
|
|
2
|
+
import {createConnection} from "node:net"
|
|
3
|
+
import {connect as tlsConnect} from "node:tls"
|
|
4
|
+
import {readFileSync} from "node:fs"
|
|
5
|
+
|
|
6
|
+
import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
|
|
7
|
+
import {AdaptiveCompressor, availableCodecs, decodeStreamPayload, encodeStreamPayload, getMaxDecompressedBytes} from "../protocol/compression.js"
|
|
8
|
+
import {encodeCompactAck, encodeCompactData, encodeFrame, FrameDecoder} from "../protocol/frame.js"
|
|
9
|
+
import {SessionSpool as SessionSpoolStorage} from "../spool/spool-file.js"
|
|
10
|
+
|
|
11
|
+
const RELAY_CONNECT_TIMEOUT_MS = 10_000
|
|
12
|
+
const SESSION_CLOSE_TIMEOUT_MS = 1_000
|
|
13
|
+
export const kCloseSocketductSession = Symbol("closeSocketductSession")
|
|
14
|
+
|
|
15
|
+
export class SocketductRelayError extends Error {
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} message - Safe relay-provided error message.
|
|
18
|
+
* @param {{code?: string, retryable?: boolean}} [details] - Machine-readable relay error details.
|
|
19
|
+
*/
|
|
20
|
+
constructor(message, details = {}) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = "SocketductRelayError"
|
|
23
|
+
this.code = details.code ?? "RELAY_ERROR"
|
|
24
|
+
this.retryable = details.retryable ?? false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} err - Error to inspect.
|
|
30
|
+
* @returns {boolean} - True when the error is a TLS verification/auth failure
|
|
31
|
+
* that should not be retried (wrong CA, hostname mismatch, self-signed,
|
|
32
|
+
* expired/revoked cert, or equivalent).
|
|
33
|
+
*/
|
|
34
|
+
function isTlsVerificationError(err) {
|
|
35
|
+
if (!(err instanceof Error)) return false
|
|
36
|
+
const code = /** @type {string | undefined} */ ((/** @type {{code?: unknown}} */ (err)).code)
|
|
37
|
+
if (typeof code !== "string") return false
|
|
38
|
+
if (code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_")) return true
|
|
39
|
+
// OpenSSL error codes surfaced by Node.js TLS for cert/verify failures
|
|
40
|
+
if (code.includes("CERT_") || code.includes("_SIGNED_CERT") || code.startsWith("UNABLE_TO_")) return true
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {import('../spool/spool-file.js').SessionSpool} SessionSpool
|
|
46
|
+
* @typedef {import('../spool/spool-file.js').SessionSpoolOptions} SessionSpoolOptions
|
|
47
|
+
* @typedef {{host: string, port: number}} SocketductTarget
|
|
48
|
+
* @typedef {object} SocketductTlsOptions
|
|
49
|
+
* @property {string} [caFile] - Path to CA certificate file for server verification.
|
|
50
|
+
* @property {string} [certFile] - Path to client certificate file for mTLS (requires keyFile).
|
|
51
|
+
* @property {string} [keyFile] - Path to client private key file for mTLS (requires certFile).
|
|
52
|
+
* @property {string} [servername] - SNI hostname for certificate identity validation.
|
|
53
|
+
* @property {boolean} [rejectUnauthorized] - Whether to reject unauthorized certificates. Defaults to true.
|
|
54
|
+
* @typedef {object} SocketductSocketOptions
|
|
55
|
+
* @property {string} relayHost - Relay host.
|
|
56
|
+
* @property {number} relayPort - Relay port.
|
|
57
|
+
* @property {string} token - Auth token.
|
|
58
|
+
* @property {string} sessionName - Human-readable session name.
|
|
59
|
+
* @property {SocketductTarget} target - Target requested for this session.
|
|
60
|
+
* @property {SessionSpool} spool - Spool storage.
|
|
61
|
+
* @property {number} [reconnectDelayMs] - Reconnect delay.
|
|
62
|
+
* @property {SocketductTlsOptions} [tls] - TLS client options.
|
|
63
|
+
* @property {boolean} [compressionEnabled] - Whether to offer stream.data compression when the runtime supports it. Default true.
|
|
64
|
+
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - Compression size and expansion-ratio bounds.
|
|
65
|
+
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
|
|
66
|
+
* @typedef {object} SocketductSocketDependencies
|
|
67
|
+
* @property {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} [createRelaySocket] - Raw relay socket factory.
|
|
68
|
+
* @property {(callback: () => void, delay: number) => unknown} [setTimeout] - Timer factory.
|
|
69
|
+
* @property {(timer: unknown) => void} [clearTimeout] - Timer cleanup.
|
|
70
|
+
* @typedef {object} ConnectSocketductOptions
|
|
71
|
+
* @property {{host: string, port: number, token: string}} relay - Relay connection options.
|
|
72
|
+
* @property {string} sessionName - Human-readable session name.
|
|
73
|
+
* @property {SocketductTarget} target - Target requested for this session.
|
|
74
|
+
* @property {string} spoolDirectory - Spool directory.
|
|
75
|
+
* @property {{initialDelayMs?: number}} [reconnect] - Reconnect options.
|
|
76
|
+
* @property {SocketductTlsOptions} [tls] - TLS client options.
|
|
77
|
+
* @property {number} [maxBytes] - Maximum total spool bytes per session.
|
|
78
|
+
* @property {number} [maxAgeMs] - Maximum age of a spool session before operations are rejected.
|
|
79
|
+
* @property {boolean} [compressionEnabled] - Whether to offer stream.data compression when the runtime supports it. Default true.
|
|
80
|
+
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - Compression size and expansion-ratio bounds.
|
|
81
|
+
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {ConnectSocketductOptions} options - Public socket options.
|
|
86
|
+
* @param {() => void} [listener] - Optional connect listener.
|
|
87
|
+
* @returns {SocketductSocket} - Connected Socketduct socket.
|
|
88
|
+
*/
|
|
89
|
+
export function connectSocketduct(options, listener) {
|
|
90
|
+
/** @type {SessionSpoolOptions} */
|
|
91
|
+
const spoolOptions = {rootDir: options.spoolDirectory}
|
|
92
|
+
if (options.maxBytes !== undefined) {
|
|
93
|
+
spoolOptions.maxBytes = options.maxBytes
|
|
94
|
+
}
|
|
95
|
+
if (options.maxAgeMs !== undefined) {
|
|
96
|
+
spoolOptions.maxAgeMs = options.maxAgeMs
|
|
97
|
+
}
|
|
98
|
+
/** @type {SocketductSocketOptions} */
|
|
99
|
+
const socketOptions = {
|
|
100
|
+
relayHost: options.relay.host,
|
|
101
|
+
relayPort: options.relay.port,
|
|
102
|
+
token: options.relay.token,
|
|
103
|
+
sessionName: options.sessionName,
|
|
104
|
+
target: options.target,
|
|
105
|
+
spool: new SessionSpoolStorage(spoolOptions)
|
|
106
|
+
}
|
|
107
|
+
if (options.reconnect?.initialDelayMs !== undefined) {
|
|
108
|
+
socketOptions.reconnectDelayMs = options.reconnect.initialDelayMs
|
|
109
|
+
}
|
|
110
|
+
if (options.tls !== undefined) {
|
|
111
|
+
socketOptions.tls = options.tls
|
|
112
|
+
}
|
|
113
|
+
if (options.compressionEnabled !== undefined) {
|
|
114
|
+
socketOptions.compressionEnabled = options.compressionEnabled
|
|
115
|
+
}
|
|
116
|
+
if (options.compressionLimits !== undefined) {
|
|
117
|
+
socketOptions.compressionLimits = options.compressionLimits
|
|
118
|
+
}
|
|
119
|
+
if (options.logger !== undefined) {
|
|
120
|
+
socketOptions.logger = options.logger
|
|
121
|
+
}
|
|
122
|
+
const socket = new SocketductSocket(socketOptions)
|
|
123
|
+
if (listener !== undefined) {
|
|
124
|
+
socket.once("connect", listener)
|
|
125
|
+
}
|
|
126
|
+
socket.connect()
|
|
127
|
+
return socket
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class SocketductSocket extends Duplex {
|
|
131
|
+
/** @type {SocketductSocketOptions} */
|
|
132
|
+
#options
|
|
133
|
+
|
|
134
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
135
|
+
#relaySocket
|
|
136
|
+
|
|
137
|
+
/** @type {Record<string, unknown> | undefined} */
|
|
138
|
+
#tlsContext
|
|
139
|
+
|
|
140
|
+
/** @type {string | undefined} */
|
|
141
|
+
#sessionId
|
|
142
|
+
|
|
143
|
+
/** @type {number} */
|
|
144
|
+
#sequence = 0
|
|
145
|
+
|
|
146
|
+
/** @type {number} */
|
|
147
|
+
#ackedSequence = 0
|
|
148
|
+
|
|
149
|
+
/** @type {number} */
|
|
150
|
+
#targetReceivedSequence = 0
|
|
151
|
+
|
|
152
|
+
/** @type {boolean} */
|
|
153
|
+
#relayWritable = false
|
|
154
|
+
|
|
155
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
156
|
+
#authenticatedRelaySocket
|
|
157
|
+
|
|
158
|
+
/** Codec negotiated on the current relay connection; undefined means uncompressed stream data. */
|
|
159
|
+
/** @type {string | undefined} */
|
|
160
|
+
#codec
|
|
161
|
+
|
|
162
|
+
/** @type {number | undefined} */
|
|
163
|
+
#framing
|
|
164
|
+
|
|
165
|
+
/** @type {AdaptiveCompressor | undefined} */
|
|
166
|
+
#compressor
|
|
167
|
+
|
|
168
|
+
/** @type {boolean} */
|
|
169
|
+
#closed = false
|
|
170
|
+
|
|
171
|
+
/** @type {boolean} */
|
|
172
|
+
#applicationEof = false
|
|
173
|
+
|
|
174
|
+
/** @type {boolean} */
|
|
175
|
+
#spoolCleanedUp = false
|
|
176
|
+
|
|
177
|
+
/** @type {boolean} */
|
|
178
|
+
#connectStarted = false
|
|
179
|
+
|
|
180
|
+
/** @type {boolean} */
|
|
181
|
+
#connectEmitted = false
|
|
182
|
+
|
|
183
|
+
/** @type {number} */
|
|
184
|
+
#physicalAttempts = 0
|
|
185
|
+
|
|
186
|
+
/** @type {number} */
|
|
187
|
+
#physicalConnects = 0
|
|
188
|
+
|
|
189
|
+
/** @type {number} */
|
|
190
|
+
#generation = 0
|
|
191
|
+
|
|
192
|
+
/** @type {unknown} */
|
|
193
|
+
#reconnectTimer
|
|
194
|
+
|
|
195
|
+
/** @type {((options: {destroySocket: boolean, reconnect: boolean, error?: unknown}) => void) | undefined} */
|
|
196
|
+
#terminateRelayAttempt
|
|
197
|
+
|
|
198
|
+
/** @type {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} */
|
|
199
|
+
#createRelaySocket
|
|
200
|
+
|
|
201
|
+
/** @type {(callback: () => void, delay: number) => unknown} */
|
|
202
|
+
#setTimeout
|
|
203
|
+
|
|
204
|
+
/** @type {(timer: unknown) => void} */
|
|
205
|
+
#clearTimeout
|
|
206
|
+
|
|
207
|
+
/** @type {unknown} */
|
|
208
|
+
#timeoutTimer
|
|
209
|
+
|
|
210
|
+
/** @type {number} */
|
|
211
|
+
#timeoutMs = 0
|
|
212
|
+
|
|
213
|
+
/** @type {Promise<void>} */
|
|
214
|
+
#spoolQueue = Promise.resolve()
|
|
215
|
+
|
|
216
|
+
/** @type {Promise<void>} */
|
|
217
|
+
#relayFrameQueue = Promise.resolve()
|
|
218
|
+
|
|
219
|
+
/** @type {Promise<void | boolean>} */
|
|
220
|
+
#sendQueue = Promise.resolve()
|
|
221
|
+
|
|
222
|
+
/** @type {Promise<boolean> | undefined} */
|
|
223
|
+
#sessionClosePromise
|
|
224
|
+
|
|
225
|
+
/** @type {((sent: boolean) => void) | undefined} */
|
|
226
|
+
#resolveSessionClose
|
|
227
|
+
|
|
228
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
229
|
+
#sessionCloseTimer
|
|
230
|
+
|
|
231
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
232
|
+
#sessionCloseRelay
|
|
233
|
+
|
|
234
|
+
/** @type {boolean} */
|
|
235
|
+
#sessionCloseAcknowledged = false
|
|
236
|
+
|
|
237
|
+
/** @type {number} */
|
|
238
|
+
bytesRead = 0
|
|
239
|
+
|
|
240
|
+
/** @type {number} */
|
|
241
|
+
bytesWritten = 0
|
|
242
|
+
|
|
243
|
+
/** @type {boolean} */
|
|
244
|
+
connecting = false
|
|
245
|
+
|
|
246
|
+
/** @type {boolean} */
|
|
247
|
+
pending = true
|
|
248
|
+
|
|
249
|
+
/** @type {string | undefined} */
|
|
250
|
+
remoteAddress
|
|
251
|
+
|
|
252
|
+
/** @type {number | undefined} */
|
|
253
|
+
remotePort
|
|
254
|
+
|
|
255
|
+
/** @type {string | undefined} */
|
|
256
|
+
localAddress
|
|
257
|
+
|
|
258
|
+
/** @type {number | undefined} */
|
|
259
|
+
localPort
|
|
260
|
+
|
|
261
|
+
/** @returns {string | undefined} - Current session identifier. */
|
|
262
|
+
get sessionIdentifier() {
|
|
263
|
+
return this.#sessionId
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {SocketductSocketOptions} options - Socket options.
|
|
268
|
+
* @param {SocketductSocketDependencies} [dependencies] - Runtime dependencies.
|
|
269
|
+
*/
|
|
270
|
+
constructor(options, dependencies = {}) {
|
|
271
|
+
super()
|
|
272
|
+
getMaxDecompressedBytes(options.compressionLimits)
|
|
273
|
+
this.#options = options
|
|
274
|
+
this.#createRelaySocket = dependencies.createRelaySocket ?? ((relayOptions) => {
|
|
275
|
+
if (relayOptions.tls !== undefined) {
|
|
276
|
+
return tlsConnect({
|
|
277
|
+
host: relayOptions.host,
|
|
278
|
+
port: relayOptions.port,
|
|
279
|
+
...relayOptions.tls
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
return createConnection({host: relayOptions.host, port: relayOptions.port})
|
|
283
|
+
})
|
|
284
|
+
this.#setTimeout = dependencies.setTimeout ?? ((callback, delay) => setTimeout(callback, delay))
|
|
285
|
+
this.#clearTimeout = dependencies.clearTimeout ?? ((timer) => clearTimeout(/** @type {ReturnType<typeof setTimeout>} */ (timer)))
|
|
286
|
+
this.remoteAddress = options.target.host
|
|
287
|
+
this.remotePort = options.target.port
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Starts the relay connection.
|
|
292
|
+
* @returns {this} - This socket.
|
|
293
|
+
*/
|
|
294
|
+
connect() {
|
|
295
|
+
if (this.#connectStarted || this.destroyed) {
|
|
296
|
+
return this
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.#connectStarted = true
|
|
300
|
+
this.connecting = true
|
|
301
|
+
logDiagnostic(this.#options.logger, "virtual_stream.opening")
|
|
302
|
+
this.#connectRelay()
|
|
303
|
+
return this
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @param {number} timeout - Timeout in milliseconds.
|
|
308
|
+
* @param {() => void} [callback] - Timeout callback.
|
|
309
|
+
* @returns {this} - This socket.
|
|
310
|
+
*/
|
|
311
|
+
setTimeout(timeout, callback) {
|
|
312
|
+
if (callback !== undefined) {
|
|
313
|
+
this.once("timeout", callback)
|
|
314
|
+
}
|
|
315
|
+
this.#timeoutMs = timeout
|
|
316
|
+
this.#clearTimeoutTimer()
|
|
317
|
+
if (timeout > 0) {
|
|
318
|
+
this.#armTimeoutTimer()
|
|
319
|
+
}
|
|
320
|
+
return this
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @returns {void} - Clears the current idle timeout. */
|
|
324
|
+
#clearTimeoutTimer() {
|
|
325
|
+
if (this.#timeoutTimer !== undefined) {
|
|
326
|
+
this.#clearTimeout(this.#timeoutTimer)
|
|
327
|
+
this.#timeoutTimer = undefined
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** @returns {void} - Arms the idle timeout, resetting it on each call. */
|
|
332
|
+
#armTimeoutTimer() {
|
|
333
|
+
this.#clearTimeoutTimer()
|
|
334
|
+
if (this.#timeoutMs > 0) {
|
|
335
|
+
const timer = this.#setTimeout(() => {
|
|
336
|
+
if (this.#timeoutTimer !== timer) return
|
|
337
|
+
this.#timeoutTimer = undefined
|
|
338
|
+
this.emit("timeout")
|
|
339
|
+
}, this.#timeoutMs)
|
|
340
|
+
this.#timeoutTimer = timer
|
|
341
|
+
const unref = /** @type {{unref?: () => void}} */ (timer)?.unref
|
|
342
|
+
unref?.call(timer)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @returns {void} - Resets the idle timeout on socket activity. */
|
|
347
|
+
#resetTimeoutTimer() {
|
|
348
|
+
if (this.#timeoutMs > 0) {
|
|
349
|
+
this.#armTimeoutTimer()
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** @returns {this} - This socket. */
|
|
354
|
+
setNoDelay() {
|
|
355
|
+
return this
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @returns {this} - This socket. */
|
|
359
|
+
setKeepAlive() {
|
|
360
|
+
return this
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** @returns {{address: string, family: string, port: number}} - Socket address. */
|
|
364
|
+
address() {
|
|
365
|
+
return {
|
|
366
|
+
address: this.localAddress ?? "0.0.0.0",
|
|
367
|
+
family: "IPv4",
|
|
368
|
+
port: this.localPort ?? 0
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** @returns {this} - This socket. */
|
|
373
|
+
ref() {
|
|
374
|
+
this.#relaySocket?.ref()
|
|
375
|
+
return this
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** @returns {this} - This socket. */
|
|
379
|
+
unref() {
|
|
380
|
+
this.#relaySocket?.unref()
|
|
381
|
+
return this
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Sends the logical session close control exactly once when the relay is
|
|
386
|
+
* available. Transport owners use this before destroying a wrapped socket.
|
|
387
|
+
* @returns {Promise<boolean>} True when the close frame reached the relay socket.
|
|
388
|
+
*/
|
|
389
|
+
[kCloseSocketductSession]() {
|
|
390
|
+
if (this.#sessionClosePromise !== undefined) return this.#sessionClosePromise
|
|
391
|
+
this.#closed = true
|
|
392
|
+
this.#relayWritable = false
|
|
393
|
+
const relaySocket = this.#relaySocket
|
|
394
|
+
if (this.#sessionId === undefined) {
|
|
395
|
+
if (this.#reconnectTimer !== undefined) {
|
|
396
|
+
this.#clearTimeout(this.#reconnectTimer)
|
|
397
|
+
this.#reconnectTimer = undefined
|
|
398
|
+
}
|
|
399
|
+
this.#sessionClosePromise = Promise.resolve(false)
|
|
400
|
+
return this.#sessionClosePromise
|
|
401
|
+
}
|
|
402
|
+
this.#sessionClosePromise = new Promise((resolve) => {
|
|
403
|
+
this.#resolveSessionClose = resolve
|
|
404
|
+
this.#sessionCloseTimer = setTimeout(() => this.#settleSessionClose(false), SESSION_CLOSE_TIMEOUT_MS)
|
|
405
|
+
this.#sessionCloseTimer.unref?.()
|
|
406
|
+
})
|
|
407
|
+
if (relaySocket !== undefined && this.#authenticatedRelaySocket === relaySocket && !relaySocket.destroyed) {
|
|
408
|
+
this.#sendSessionClose(relaySocket)
|
|
409
|
+
} else if (relaySocket === undefined || relaySocket.destroyed) {
|
|
410
|
+
if (this.#reconnectTimer !== undefined) {
|
|
411
|
+
this.#clearTimeout(this.#reconnectTimer)
|
|
412
|
+
this.#reconnectTimer = undefined
|
|
413
|
+
}
|
|
414
|
+
this.#connectRelay()
|
|
415
|
+
}
|
|
416
|
+
return this.#sessionClosePromise
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Sends close only on the relay connection that has authenticated and
|
|
421
|
+
* completed create/resume. A failed transport remains eligible for the
|
|
422
|
+
* bounded close-only reconnect until the close acknowledgement deadline.
|
|
423
|
+
* @param {import('node:net').Socket} relaySocket - Authenticated relay socket.
|
|
424
|
+
*/
|
|
425
|
+
#sendSessionClose(relaySocket) {
|
|
426
|
+
if (!this.#needsRelayForClose() || this.#relaySocket !== relaySocket || this.#authenticatedRelaySocket !== relaySocket || relaySocket.destroyed || this.#sessionCloseRelay === relaySocket) return
|
|
427
|
+
this.#sessionCloseRelay = relaySocket
|
|
428
|
+
void this.#writeFrameToRelay(relaySocket, {type: "session.close", direction: "client-to-relay"})
|
|
429
|
+
.then((sent) => {
|
|
430
|
+
if (!sent && this.#relaySocket === relaySocket && !relaySocket.destroyed) relaySocket.destroy()
|
|
431
|
+
})
|
|
432
|
+
.catch((error) => relaySocket.destroy(error instanceof Error ? error : new Error(String(error))))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** @returns {boolean} Whether destruction is waiting for an authenticated logical close. */
|
|
436
|
+
#needsRelayForClose() {
|
|
437
|
+
return this.#closed && this.#sessionId !== undefined && this.#resolveSessionClose !== undefined && !this.#sessionCloseAcknowledged
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** @returns {boolean} Whether a normal or close-only relay attempt may run. */
|
|
441
|
+
#mayConnectRelay() {
|
|
442
|
+
return (!this.#closed && !this.destroyed) || this.#needsRelayForClose()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Ends the application-facing readable side exactly once. */
|
|
446
|
+
#endApplicationReadable() {
|
|
447
|
+
if (this.#applicationEof) return
|
|
448
|
+
this.#applicationEof = true
|
|
449
|
+
if (!this.destroyed) this.push(null)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** @param {boolean} sent */
|
|
453
|
+
#settleSessionClose(sent) {
|
|
454
|
+
if (this.#resolveSessionClose === undefined) return
|
|
455
|
+
const resolve = this.#resolveSessionClose
|
|
456
|
+
this.#resolveSessionClose = undefined
|
|
457
|
+
if (this.#sessionCloseTimer !== undefined) {
|
|
458
|
+
clearTimeout(this.#sessionCloseTimer)
|
|
459
|
+
this.#sessionCloseTimer = undefined
|
|
460
|
+
}
|
|
461
|
+
this.#sessionCloseRelay = undefined
|
|
462
|
+
resolve(sent)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* @override
|
|
467
|
+
* @returns {void} - Pulls data from the internal buffer.
|
|
468
|
+
*/
|
|
469
|
+
_read() {
|
|
470
|
+
this.#relaySocket?.resume()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @override
|
|
475
|
+
* @param {Buffer} chunk - Data chunk.
|
|
476
|
+
* @param {string} _encoding - Encoding value.
|
|
477
|
+
* @param {(error?: Error | null) => void} callback - Completion callback.
|
|
478
|
+
* @returns {void} - Writes chunk to Socketduct.
|
|
479
|
+
*/
|
|
480
|
+
_write(chunk, _encoding, callback) {
|
|
481
|
+
this.#resetTimeoutTimer()
|
|
482
|
+
this.#handleAppData(chunk)
|
|
483
|
+
.then(() => callback())
|
|
484
|
+
.catch((error) => callback(error instanceof Error ? error : new Error(String(error))))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Node calls this only for writes already queued behind an in-flight async
|
|
489
|
+
* write. Merge those original bytes without delaying a sole chunk.
|
|
490
|
+
* @override
|
|
491
|
+
* @param {{chunk: Buffer, encoding: string}[]} chunks - Already queued writes.
|
|
492
|
+
* @param {(error?: Error | null) => void} callback - Completion callback.
|
|
493
|
+
*/
|
|
494
|
+
_writev(chunks, callback) {
|
|
495
|
+
this.#resetTimeoutTimer()
|
|
496
|
+
const maxBytes = getMaxDecompressedBytes(this.#options.compressionLimits)
|
|
497
|
+
const groups = []
|
|
498
|
+
let pending = []
|
|
499
|
+
let pendingBytes = 0
|
|
500
|
+
for (const entry of chunks) {
|
|
501
|
+
let offset = 0
|
|
502
|
+
while (offset < entry.chunk.length) {
|
|
503
|
+
const available = maxBytes - pendingBytes
|
|
504
|
+
const length = Math.min(available, entry.chunk.length - offset)
|
|
505
|
+
pending.push(entry.chunk.subarray(offset, offset + length))
|
|
506
|
+
pendingBytes += length
|
|
507
|
+
offset += length
|
|
508
|
+
if (pendingBytes === maxBytes) {
|
|
509
|
+
groups.push(Buffer.concat(pending, pendingBytes))
|
|
510
|
+
pending = []
|
|
511
|
+
pendingBytes = 0
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (pendingBytes > 0) groups.push(Buffer.concat(pending, pendingBytes))
|
|
516
|
+
groups.reduce((work, group) => work.then(() => this.#handleAppData(group)), Promise.resolve())
|
|
517
|
+
.then(() => callback())
|
|
518
|
+
.catch((error) => callback(error instanceof Error ? error : new Error(String(error))))
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* @override
|
|
523
|
+
* @param {(error?: Error | null) => void} callback - Completion callback.
|
|
524
|
+
* @returns {void} - Ends the socket.
|
|
525
|
+
*/
|
|
526
|
+
_final(callback) {
|
|
527
|
+
this.#closed = true
|
|
528
|
+
if (this.#reconnectTimer !== undefined) {
|
|
529
|
+
this.#clearTimeout(this.#reconnectTimer)
|
|
530
|
+
this.#reconnectTimer = undefined
|
|
531
|
+
}
|
|
532
|
+
this.#relayWritable = false
|
|
533
|
+
const relaySocket = this.#relaySocket
|
|
534
|
+
if (relaySocket !== undefined && !relaySocket.destroyed && this.#sessionId !== undefined) {
|
|
535
|
+
this[kCloseSocketductSession]()
|
|
536
|
+
.then(() => {
|
|
537
|
+
relaySocket.destroy()
|
|
538
|
+
return this.#cleanupSpool()
|
|
539
|
+
})
|
|
540
|
+
.then(() => {
|
|
541
|
+
this.#endApplicationReadable()
|
|
542
|
+
callback()
|
|
543
|
+
})
|
|
544
|
+
.catch((error) => callback(error))
|
|
545
|
+
} else {
|
|
546
|
+
if (this.#terminateRelayAttempt !== undefined) {
|
|
547
|
+
this.#terminateRelayAttempt({destroySocket: true, reconnect: false})
|
|
548
|
+
} else {
|
|
549
|
+
relaySocket?.destroy()
|
|
550
|
+
}
|
|
551
|
+
this.#cleanupSpool()
|
|
552
|
+
.then(() => {
|
|
553
|
+
this.#endApplicationReadable()
|
|
554
|
+
callback()
|
|
555
|
+
})
|
|
556
|
+
.catch((error) => callback(error))
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @override
|
|
562
|
+
* @param {Error | null} error - Destroy error.
|
|
563
|
+
* @param {(error?: Error | null) => void} callback - Completion callback.
|
|
564
|
+
* @returns {void} - Destroys resources.
|
|
565
|
+
*/
|
|
566
|
+
_destroy(error, callback) {
|
|
567
|
+
this.#applicationEof = true
|
|
568
|
+
this.#closed = true
|
|
569
|
+
this.#relayWritable = false
|
|
570
|
+
this.connecting = false
|
|
571
|
+
this.pending = false
|
|
572
|
+
logDiagnostic(this.#options.logger, error === null ? "virtual_stream.closed" : "virtual_stream.error", error === null ? {} : errorDiagnostics(error))
|
|
573
|
+
if (this.#reconnectTimer !== undefined) {
|
|
574
|
+
this.#clearTimeout(this.#reconnectTimer)
|
|
575
|
+
this.#reconnectTimer = undefined
|
|
576
|
+
}
|
|
577
|
+
if (this.#timeoutTimer !== undefined) {
|
|
578
|
+
this.#clearTimeout(this.#timeoutTimer)
|
|
579
|
+
this.#timeoutTimer = undefined
|
|
580
|
+
}
|
|
581
|
+
const relaySocket = this.#relaySocket
|
|
582
|
+
this[kCloseSocketductSession]()
|
|
583
|
+
.then(() => {
|
|
584
|
+
if (this.#terminateRelayAttempt !== undefined) {
|
|
585
|
+
this.#terminateRelayAttempt({destroySocket: true, reconnect: false})
|
|
586
|
+
} else {
|
|
587
|
+
relaySocket?.destroy()
|
|
588
|
+
}
|
|
589
|
+
return this.#cleanupSpool()
|
|
590
|
+
})
|
|
591
|
+
.then(() => callback(error))
|
|
592
|
+
.catch((cleanupError) => {
|
|
593
|
+
callback(error ?? cleanupError)
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* @returns {Promise<void>} - Resolves after spool directory is removed.
|
|
599
|
+
*/
|
|
600
|
+
async #cleanupSpool() {
|
|
601
|
+
if (this.#spoolCleanedUp) {
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
this.#spoolCleanedUp = true
|
|
605
|
+
await this.#spoolQueue
|
|
606
|
+
await this.#relayFrameQueue.catch(() => {})
|
|
607
|
+
const sessionId = this.#sessionId
|
|
608
|
+
if (sessionId === undefined) {
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
await this.#options.spool.remove(sessionId)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** @returns {{ca?: Buffer, cert?: Buffer, key?: Buffer, servername?: string}} - Resolved TLS context. */
|
|
615
|
+
#resolveTlsContext() {
|
|
616
|
+
if (this.#tlsContext !== undefined) {
|
|
617
|
+
return /** @type {{ca?: Buffer, cert?: Buffer, key?: Buffer, servername?: string}} */ (this.#tlsContext)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const tls = /** @type {SocketductTlsOptions} */ (this.#options.tls)
|
|
621
|
+
/** @type {{ca?: Buffer, cert?: Buffer, key?: Buffer, servername?: string}} */
|
|
622
|
+
const context = {}
|
|
623
|
+
|
|
624
|
+
if (tls.servername !== undefined) {
|
|
625
|
+
context.servername = tls.servername
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (tls.caFile !== undefined) {
|
|
629
|
+
context.ca = readFileSync(tls.caFile)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (tls.certFile !== undefined || tls.keyFile !== undefined) {
|
|
633
|
+
if (tls.certFile === undefined || tls.keyFile === undefined) {
|
|
634
|
+
throw new Error("TLS client certFile and keyFile must both be set (atomic pair)")
|
|
635
|
+
}
|
|
636
|
+
context.cert = readFileSync(tls.certFile)
|
|
637
|
+
context.key = readFileSync(tls.keyFile)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
this.#tlsContext = context
|
|
641
|
+
return context
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** @returns {void} - Opens a relay connection. */
|
|
645
|
+
#connectRelay() {
|
|
646
|
+
if (!this.#mayConnectRelay()) {
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
this.#physicalAttempts += 1
|
|
651
|
+
const reconnect = this.#physicalAttempts > 1 || this.#sessionId !== undefined
|
|
652
|
+
logDiagnostic(this.#options.logger, "transport.connecting", {
|
|
653
|
+
generation: this.#generation,
|
|
654
|
+
attempt: this.#physicalAttempts,
|
|
655
|
+
reconnect
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
/** @type {import('node:net').Socket} */
|
|
659
|
+
let relaySocket
|
|
660
|
+
|
|
661
|
+
const tlsEnabled = this.#options.tls !== undefined
|
|
662
|
+
try {
|
|
663
|
+
if (tlsEnabled) {
|
|
664
|
+
const context = this.#resolveTlsContext()
|
|
665
|
+
relaySocket = this.#createRelaySocket({
|
|
666
|
+
host: this.#options.relayHost,
|
|
667
|
+
port: this.#options.relayPort,
|
|
668
|
+
tls: {
|
|
669
|
+
...context,
|
|
670
|
+
rejectUnauthorized: this.#options.tls?.rejectUnauthorized !== false
|
|
671
|
+
}
|
|
672
|
+
})
|
|
673
|
+
} else {
|
|
674
|
+
relaySocket = this.#createRelaySocket({host: this.#options.relayHost, port: this.#options.relayPort})
|
|
675
|
+
}
|
|
676
|
+
} catch (error) {
|
|
677
|
+
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
678
|
+
generation: this.#generation,
|
|
679
|
+
attempt: this.#physicalAttempts,
|
|
680
|
+
...errorDiagnostics(error)
|
|
681
|
+
})
|
|
682
|
+
this.destroy(error instanceof Error ? error : new Error(String(error)))
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
this.#relaySocket = relaySocket
|
|
687
|
+
// Compression is negotiated per connection; assume raw until the relay agrees.
|
|
688
|
+
this.#codec = undefined
|
|
689
|
+
this.#framing = undefined
|
|
690
|
+
this.#compressor = undefined
|
|
691
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
692
|
+
const readinessEvent = tlsEnabled ? "secureConnect" : "connect"
|
|
693
|
+
let attemptTerminated = false
|
|
694
|
+
/** @type {unknown} */
|
|
695
|
+
let connectTimer
|
|
696
|
+
|
|
697
|
+
const clearConnectTimer = () => {
|
|
698
|
+
if (connectTimer !== undefined) {
|
|
699
|
+
this.#clearTimeout(connectTimer)
|
|
700
|
+
connectTimer = undefined
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const removeAttemptListeners = () => {
|
|
704
|
+
relaySocket.off(readinessEvent, onReady)
|
|
705
|
+
relaySocket.off("data", onData)
|
|
706
|
+
relaySocket.off("close", onClose)
|
|
707
|
+
relaySocket.off("error", onError)
|
|
708
|
+
}
|
|
709
|
+
const ignoreLateError = () => {}
|
|
710
|
+
/** @param {{destroySocket: boolean, reconnect: boolean, error?: unknown}} options */
|
|
711
|
+
const terminateAttempt = ({destroySocket, reconnect, error}) => {
|
|
712
|
+
if (attemptTerminated) {
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
attemptTerminated = true
|
|
716
|
+
clearConnectTimer()
|
|
717
|
+
removeAttemptListeners()
|
|
718
|
+
relaySocket.on("error", ignoreLateError)
|
|
719
|
+
if (this.#terminateRelayAttempt === terminateAttempt) {
|
|
720
|
+
this.#terminateRelayAttempt = undefined
|
|
721
|
+
}
|
|
722
|
+
if (this.#relaySocket === relaySocket) {
|
|
723
|
+
this.#relaySocket = undefined
|
|
724
|
+
}
|
|
725
|
+
if (this.#authenticatedRelaySocket === relaySocket) {
|
|
726
|
+
this.#authenticatedRelaySocket = undefined
|
|
727
|
+
}
|
|
728
|
+
if (this.#sessionCloseRelay === relaySocket) {
|
|
729
|
+
this.#sessionCloseRelay = undefined
|
|
730
|
+
}
|
|
731
|
+
this.#relayWritable = false
|
|
732
|
+
if (destroySocket && !relaySocket.destroyed) {
|
|
733
|
+
relaySocket.destroy()
|
|
734
|
+
}
|
|
735
|
+
if (reconnect && !this.#closed) {
|
|
736
|
+
logDiagnostic(this.#options.logger, "transport.disconnected", {
|
|
737
|
+
generation: this.#generation,
|
|
738
|
+
virtualStreams: this.destroyed ? 0 : 1,
|
|
739
|
+
...errorDiagnostics(error, "TRANSPORT_CLOSED")
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
if (reconnect && this.#mayConnectRelay()) {
|
|
743
|
+
if (this.#needsRelayForClose()) {
|
|
744
|
+
this.#connectRelay()
|
|
745
|
+
} else {
|
|
746
|
+
this.#scheduleReconnect(error)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const onReady = () => {
|
|
751
|
+
if (attemptTerminated) {
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
clearConnectTimer()
|
|
755
|
+
relaySocket.off(readinessEvent, onReady)
|
|
756
|
+
this.localAddress = relaySocket.localAddress
|
|
757
|
+
this.localPort = relaySocket.localPort
|
|
758
|
+
const offer = this.#offeredCodecs()
|
|
759
|
+
const header = this.#sessionId === undefined
|
|
760
|
+
? {
|
|
761
|
+
type: "session.create",
|
|
762
|
+
token: this.#options.token,
|
|
763
|
+
name: this.#options.sessionName,
|
|
764
|
+
target: this.#options.target,
|
|
765
|
+
...(offer.length > 0 ? {compression: offer} : {}),
|
|
766
|
+
framing: [2]
|
|
767
|
+
}
|
|
768
|
+
: {
|
|
769
|
+
type: "session.resume",
|
|
770
|
+
token: this.#options.token,
|
|
771
|
+
identifier: this.#sessionId,
|
|
772
|
+
targetToClientAck: this.#targetReceivedSequence,
|
|
773
|
+
...(offer.length > 0 ? {compression: offer} : {}),
|
|
774
|
+
framing: [2]
|
|
775
|
+
}
|
|
776
|
+
this.#writeFrameToRelay(relaySocket, header).catch((error) => this.destroy(error))
|
|
777
|
+
}
|
|
778
|
+
/** @param {Buffer} chunk */
|
|
779
|
+
const onData = (chunk) => {
|
|
780
|
+
if (attemptTerminated) {
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
let frames
|
|
784
|
+
try {
|
|
785
|
+
frames = decoder.push(chunk)
|
|
786
|
+
} catch {
|
|
787
|
+
relaySocket.destroy()
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
for (const frame of frames) {
|
|
792
|
+
this.#relayFrameQueue = this.#relayFrameQueue
|
|
793
|
+
.then(() => this.#handleRelayFrame(frame.header, frame.payload, frame.wireFormat, relaySocket))
|
|
794
|
+
.catch((error) => { this.destroy(error instanceof Error ? error : new Error(String(error))) })
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const onClose = () => {
|
|
798
|
+
if (attemptTerminated) {
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
terminateAttempt({destroySocket: false, reconnect: true})
|
|
802
|
+
}
|
|
803
|
+
/** @param {Error} err */
|
|
804
|
+
const onError = (err) => {
|
|
805
|
+
if (attemptTerminated) {
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
if (isTlsVerificationError(err)) {
|
|
809
|
+
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
810
|
+
generation: this.#generation,
|
|
811
|
+
attempt: this.#physicalAttempts,
|
|
812
|
+
...errorDiagnostics(err)
|
|
813
|
+
})
|
|
814
|
+
terminateAttempt({destroySocket: true, reconnect: false, error: err})
|
|
815
|
+
this.destroy(err)
|
|
816
|
+
this.#settleSessionClose(false)
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
terminateAttempt({destroySocket: true, reconnect: true, error: err})
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
this.#terminateRelayAttempt = terminateAttempt
|
|
823
|
+
relaySocket.on(readinessEvent, onReady)
|
|
824
|
+
relaySocket.on("data", onData)
|
|
825
|
+
relaySocket.on("close", onClose)
|
|
826
|
+
relaySocket.on("error", onError)
|
|
827
|
+
connectTimer = this.#setTimeout(() => {
|
|
828
|
+
terminateAttempt({destroySocket: true, reconnect: true, error: Object.assign(new Error("Socketduct relay connection timed out"), {code: "ETIMEDOUT"})})
|
|
829
|
+
}, RELAY_CONNECT_TIMEOUT_MS)
|
|
830
|
+
if (connectTimer && typeof connectTimer === "object" && "unref" in connectTimer && typeof connectTimer.unref === "function") {
|
|
831
|
+
connectTimer.unref()
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* @param {import('../protocol/frame.js').FrameHeader} header - Frame header.
|
|
837
|
+
* @param {Buffer} payload - Frame payload.
|
|
838
|
+
* @param {"compact" | "envelope"} wireFormat - Decoder-proven wire format.
|
|
839
|
+
* @param {import('node:net').Socket} relaySocket - Relay socket that produced this frame.
|
|
840
|
+
* @returns {Promise<void>} - Resolves after handling.
|
|
841
|
+
*/
|
|
842
|
+
async #handleRelayFrame(header, payload, wireFormat, relaySocket) {
|
|
843
|
+
const staleRelayFrame = this.#relaySocket !== relaySocket
|
|
844
|
+
|
|
845
|
+
if (staleRelayFrame) {
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (header.compact === true && this.#framing !== 2) {
|
|
850
|
+
throw new Error("Relay sent compact framing without negotiating version 2")
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (header.type === "session.created" || header.type === "session.resumed") {
|
|
854
|
+
if (header.framing !== 2) {
|
|
855
|
+
throw new Error("Relay did not select required framing version 2")
|
|
856
|
+
}
|
|
857
|
+
if (typeof header.identifier !== "string") {
|
|
858
|
+
throw new Error("Relay did not provide a session identifier")
|
|
859
|
+
}
|
|
860
|
+
const identifier = header.identifier
|
|
861
|
+
if (header.type === "session.created") {
|
|
862
|
+
this.#targetReceivedSequence = 0
|
|
863
|
+
await this.#options.spool.create({sessionId: identifier, ownerId: "edge"})
|
|
864
|
+
}
|
|
865
|
+
const selectedCodec = typeof header.compression === "string" ? header.compression : undefined
|
|
866
|
+
this.#codec = this.#offeredCodecs().includes(selectedCodec ?? "") ? selectedCodec : undefined
|
|
867
|
+
this.#framing = 2
|
|
868
|
+
this.#compressor = this.#codec === undefined ? undefined : new AdaptiveCompressor()
|
|
869
|
+
this.#sessionId = identifier
|
|
870
|
+
if (typeof header.serverLastAck === "number") {
|
|
871
|
+
this.#ackedSequence = Math.max(this.#ackedSequence, header.serverLastAck)
|
|
872
|
+
await this.#queueSpoolOperation(() => this.#options.spool.truncateAcked(identifier, this.#ackedSequence))
|
|
873
|
+
}
|
|
874
|
+
this.connecting = false
|
|
875
|
+
this.pending = false
|
|
876
|
+
this.#relayWritable = true
|
|
877
|
+
this.#authenticatedRelaySocket = relaySocket
|
|
878
|
+
this.#generation += 1
|
|
879
|
+
this.#physicalConnects += 1
|
|
880
|
+
const resumed = header.type === "session.resumed" || this.#physicalConnects > 1
|
|
881
|
+
logDiagnostic(this.#options.logger, "transport.connected", {
|
|
882
|
+
generation: this.#generation,
|
|
883
|
+
attempt: this.#physicalAttempts,
|
|
884
|
+
physicalConnects: this.#physicalConnects,
|
|
885
|
+
reconnect: resumed
|
|
886
|
+
})
|
|
887
|
+
if (resumed) {
|
|
888
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
|
|
889
|
+
generation: this.#generation,
|
|
890
|
+
attempt: this.#physicalAttempts,
|
|
891
|
+
physicalConnects: this.#physicalConnects
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
if (this.#needsRelayForClose()) {
|
|
895
|
+
this.#sendSessionClose(relaySocket)
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
await this.#queueSpoolOperation(() => this.#flushSpooledChunks(relaySocket))
|
|
899
|
+
if (this.#relaySocket !== relaySocket || relaySocket.destroyed) {
|
|
900
|
+
this.#relayWritable = false
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
if (!this.#connectEmitted) {
|
|
904
|
+
this.#connectEmitted = true
|
|
905
|
+
logDiagnostic(this.#options.logger, "virtual_stream.opened", {generation: this.#generation, recovered: false})
|
|
906
|
+
this.emit("connect")
|
|
907
|
+
}
|
|
908
|
+
this.emit("ready")
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (header.type === "error") {
|
|
913
|
+
const message = typeof header.message === "string" ? header.message : "Socketduct relay error"
|
|
914
|
+
const code = typeof header.code === "string" ? header.code : undefined
|
|
915
|
+
const retryable = typeof header.retryable === "boolean" ? header.retryable : undefined
|
|
916
|
+
this.destroy(new SocketductRelayError(message, {
|
|
917
|
+
...(code === undefined ? {} : {code}),
|
|
918
|
+
...(retryable === undefined ? {} : {retryable})
|
|
919
|
+
}))
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if ((header.type === "stream.data" || header.type === "stream.ack") && wireFormat !== "compact") {
|
|
924
|
+
throw new Error(`Relay sent length-prefixed ${header.type} after framing version 2 was established`)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (header.type === "stream.ack") {
|
|
928
|
+
if (typeof header.ackSequence === "number" && this.#sessionId !== undefined) {
|
|
929
|
+
this.#ackedSequence = Math.max(this.#ackedSequence, header.ackSequence)
|
|
930
|
+
await this.#queueSpoolOperation(() => this.#options.spool.truncateAcked(this.#sessionId ?? "", this.#ackedSequence))
|
|
931
|
+
}
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (header.type === "stream.data" && header.direction === "target-to-client") {
|
|
936
|
+
const sequence = header.sequence
|
|
937
|
+
if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence <= 0) {
|
|
938
|
+
throw new Error("Relay sent stream.data without a valid sequence")
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const data = await decodeStreamPayload(header, payload, this.#codec, this.#options.compressionLimits ?? {})
|
|
942
|
+
|
|
943
|
+
if (this.#applicationEof || this.destroyed) {
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Duplicate replay after a resume: already delivered, so drop it silently.
|
|
948
|
+
if (sequence <= this.#targetReceivedSequence) {
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Sequences are cumulative uncompressed byte offsets, so a contiguous frame
|
|
953
|
+
// ends exactly one payload past the last one. Fail closed on a gap or overlap
|
|
954
|
+
// rather than acknowledging bytes we never delivered.
|
|
955
|
+
const expectedSequence = this.#targetReceivedSequence + data.length
|
|
956
|
+
if (sequence !== expectedSequence) {
|
|
957
|
+
const detail = sequence < expectedSequence ? "overlap" : "gap"
|
|
958
|
+
throw new Error(`Relay stream.data sequence ${sequence} creates a ${detail} (expected contiguous offset ${expectedSequence})`)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
this.#targetReceivedSequence = sequence
|
|
962
|
+
this.bytesRead += data.length
|
|
963
|
+
this.#resetTimeoutTimer()
|
|
964
|
+
if (!this.push(data)) {
|
|
965
|
+
relaySocket.pause()
|
|
966
|
+
}
|
|
967
|
+
await this.#writeFrameToRelay(relaySocket, {type: "stream.ack", direction: "target-to-client", ackSequence: sequence})
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (header.type === "session.close" && header.direction === "target-to-client") {
|
|
972
|
+
this.#relayWritable = false
|
|
973
|
+
// Settle after this queued frame handler releases #relayFrameQueue; destroy
|
|
974
|
+
// cleanup awaits that queue before removing the spool.
|
|
975
|
+
if (header.acknowledged === true) {
|
|
976
|
+
this.#sessionCloseAcknowledged = true
|
|
977
|
+
setImmediate(() => this.#settleSessionClose(true))
|
|
978
|
+
}
|
|
979
|
+
this.#endApplicationReadable()
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* @param {Buffer} chunk - App data chunk.
|
|
985
|
+
* @returns {Promise<void>} - Resolves after spooling.
|
|
986
|
+
*/
|
|
987
|
+
async #handleAppData(chunk) {
|
|
988
|
+
if (!this.#connectStarted) {
|
|
989
|
+
this.connect()
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (this.#sessionId === undefined) {
|
|
993
|
+
await this.#waitForSession()
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (this.#closed || this.destroyed) {
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (this.#sessionId === undefined) {
|
|
1001
|
+
throw new Error("Socketduct session is not available")
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Sequences, spool offsets and byte counters stay in uncompressed units, and
|
|
1005
|
+
// the spool always stores raw chunks so a replay can be re-encoded later.
|
|
1006
|
+
this.#sequence += chunk.length
|
|
1007
|
+
this.bytesWritten += chunk.length
|
|
1008
|
+
await this.#queueSpoolOperation(() => this.#options.spool.appendChunk({
|
|
1009
|
+
sessionId: this.#sessionId ?? "",
|
|
1010
|
+
ownerId: "edge",
|
|
1011
|
+
sequence: this.#sequence,
|
|
1012
|
+
data: chunk
|
|
1013
|
+
}))
|
|
1014
|
+
|
|
1015
|
+
if (this.#relayWritable) {
|
|
1016
|
+
const {header, payload} = await this.#buildStreamDataFrame(this.#sequence, chunk)
|
|
1017
|
+
await this.#writeFrame(header, payload)
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** @returns {string[]} - Codecs this client offers, empty when compression is disabled or unsupported. */
|
|
1022
|
+
#offeredCodecs() {
|
|
1023
|
+
if (this.#options.compressionEnabled === false) {
|
|
1024
|
+
return []
|
|
1025
|
+
}
|
|
1026
|
+
return availableCodecs()
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* @param {number} sequence - Uncompressed cumulative byte offset of this chunk.
|
|
1031
|
+
* @param {Buffer} chunk - Raw payload bytes.
|
|
1032
|
+
* @returns {Promise<{header: import('../protocol/frame.js').FrameHeader, payload: Buffer}>} - Frame to send.
|
|
1033
|
+
*/
|
|
1034
|
+
async #buildStreamDataFrame(sequence, chunk) {
|
|
1035
|
+
const encoded = await encodeStreamPayload(chunk, this.#codec, this.#compressor, this.#options.compressionLimits ?? {})
|
|
1036
|
+
/** @type {import('../protocol/frame.js').FrameHeader} */
|
|
1037
|
+
const header = {type: "stream.data", direction: "client-to-target", session: this.#sessionId, sequence}
|
|
1038
|
+
if (encoded.encoding !== undefined) {
|
|
1039
|
+
header.encoding = encoded.encoding
|
|
1040
|
+
header.dataLength = encoded.dataLength
|
|
1041
|
+
}
|
|
1042
|
+
return {header, payload: encoded.payload}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* @param {() => Promise<void>} operation - Spool operation to serialize.
|
|
1047
|
+
* @returns {Promise<void>} - Resolves after the operation.
|
|
1048
|
+
*/
|
|
1049
|
+
async #queueSpoolOperation(operation) {
|
|
1050
|
+
const next = this.#spoolQueue.then(operation)
|
|
1051
|
+
this.#spoolQueue = next.catch(() => {})
|
|
1052
|
+
await next
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** @returns {Promise<void>} - Resolves when a session is available. */
|
|
1056
|
+
async #waitForSession() {
|
|
1057
|
+
while (!this.#closed && !this.destroyed && this.#sessionId === undefined) {
|
|
1058
|
+
await new Promise((resolve) => { setTimeout(resolve, 5) })
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* @param {import('node:net').Socket} relaySocket - Authenticated relay socket that should receive replayed frames.
|
|
1064
|
+
* @returns {Promise<void>} - Sends unacknowledged spool chunks.
|
|
1065
|
+
*/
|
|
1066
|
+
async #flushSpooledChunks(relaySocket) {
|
|
1067
|
+
if (this.#sessionId === undefined) {
|
|
1068
|
+
return
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
let lastSentSequence = this.#ackedSequence
|
|
1072
|
+
while (!this.#closed && !this.destroyed) {
|
|
1073
|
+
const chunks = await this.#options.spool.readChunksAfter(this.#sessionId, lastSentSequence)
|
|
1074
|
+
if (chunks.length === 0) {
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
for (const chunk of chunks) {
|
|
1079
|
+
const {header, payload} = await this.#buildStreamDataFrame(chunk.sequence, chunk.data)
|
|
1080
|
+
const sent = await this.#writeFrameToRelay(relaySocket, header, payload)
|
|
1081
|
+
if (!sent) {
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
lastSentSequence = Math.max(lastSentSequence, chunk.sequence)
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* @param {import('node:net').Socket} relaySocket - Relay socket that should receive the frame.
|
|
1091
|
+
* @param {import('../protocol/frame.js').FrameHeader} header - Frame header.
|
|
1092
|
+
* @param {Buffer} [payload] - Frame payload.
|
|
1093
|
+
* @returns {Promise<boolean>} - Resolves true when sent, false if the relay was interrupted.
|
|
1094
|
+
*/
|
|
1095
|
+
async #writeFrameToRelay(relaySocket, header, payload = Buffer.alloc(0)) {
|
|
1096
|
+
const operation = this.#sendQueue.then(() => this.#sendFrameToRelayNow(relaySocket, header, payload))
|
|
1097
|
+
this.#sendQueue = operation.catch(() => {})
|
|
1098
|
+
return operation
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* @param {import('../protocol/frame.js').FrameHeader} header - Frame header.
|
|
1103
|
+
* @param {Buffer} [payload] - Frame payload.
|
|
1104
|
+
* @returns {Promise<boolean>} - Resolves true when sent, false if the relay was interrupted.
|
|
1105
|
+
*/
|
|
1106
|
+
async #writeFrame(header, payload = Buffer.alloc(0)) {
|
|
1107
|
+
const relaySocket = this.#relaySocket
|
|
1108
|
+
if (relaySocket === undefined) {
|
|
1109
|
+
return false
|
|
1110
|
+
}
|
|
1111
|
+
return this.#writeFrameToRelay(relaySocket, header, payload)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* @param {import('node:net').Socket} relaySocket - Relay socket that should receive the frame.
|
|
1116
|
+
* @param {import('../protocol/frame.js').FrameHeader} header - Frame header.
|
|
1117
|
+
* @param {Buffer} [payload] - Frame payload.
|
|
1118
|
+
* @returns {Promise<boolean>} - Resolves true when sent, false if the relay was interrupted.
|
|
1119
|
+
*/
|
|
1120
|
+
async #sendFrameToRelayNow(relaySocket, header, payload = Buffer.alloc(0)) {
|
|
1121
|
+
if (this.#relaySocket !== relaySocket || relaySocket.destroyed) {
|
|
1122
|
+
return false
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
let encodedFrame
|
|
1126
|
+
if (this.#framing === 2 && header.type === "stream.data") {
|
|
1127
|
+
encodedFrame = encodeCompactData({sequence: /** @type {number} */ (header.sequence), payload, ...(header.encoding === "zstd" ? {dataLength: /** @type {number} */ (header.dataLength)} : {})})
|
|
1128
|
+
} else if (this.#framing === 2 && header.type === "stream.ack") {
|
|
1129
|
+
encodedFrame = encodeCompactAck(/** @type {number} */ (header.ackSequence))
|
|
1130
|
+
} else {
|
|
1131
|
+
encodedFrame = encodeFrame(header, payload)
|
|
1132
|
+
}
|
|
1133
|
+
if (!relaySocket.write(encodedFrame)) {
|
|
1134
|
+
return new Promise((resolve) => {
|
|
1135
|
+
const onDrain = () => {
|
|
1136
|
+
cleanup()
|
|
1137
|
+
resolve(true)
|
|
1138
|
+
}
|
|
1139
|
+
const onClose = () => {
|
|
1140
|
+
cleanup()
|
|
1141
|
+
resolve(false)
|
|
1142
|
+
}
|
|
1143
|
+
const cleanup = () => {
|
|
1144
|
+
relaySocket.off("drain", onDrain)
|
|
1145
|
+
relaySocket.off("close", onClose)
|
|
1146
|
+
}
|
|
1147
|
+
relaySocket.once("drain", onDrain)
|
|
1148
|
+
relaySocket.once("close", onClose)
|
|
1149
|
+
})
|
|
1150
|
+
}
|
|
1151
|
+
return true
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* @param {unknown} [error] - Safe-to-classify reconnect cause.
|
|
1156
|
+
* @returns {void} Schedules a reconnect.
|
|
1157
|
+
*/
|
|
1158
|
+
#scheduleReconnect(error) {
|
|
1159
|
+
if (!this.#mayConnectRelay() || this.#reconnectTimer) {
|
|
1160
|
+
return
|
|
1161
|
+
}
|
|
1162
|
+
const delayMs = this.#options.reconnectDelayMs ?? 25
|
|
1163
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_scheduled", {
|
|
1164
|
+
generation: this.#generation,
|
|
1165
|
+
attempt: this.#physicalAttempts,
|
|
1166
|
+
delayMs,
|
|
1167
|
+
...errorDiagnostics(error, "TRANSPORT_CLOSED")
|
|
1168
|
+
})
|
|
1169
|
+
this.#reconnectTimer = this.#setTimeout(() => {
|
|
1170
|
+
this.#reconnectTimer = undefined
|
|
1171
|
+
this.#connectRelay()
|
|
1172
|
+
}, delayMs)
|
|
1173
|
+
}
|
|
1174
|
+
}
|