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,1504 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns, jsdoc/require-returns-description */
|
|
2
|
+
import {randomBytes} from "node:crypto"
|
|
3
|
+
import {EventEmitter} from "node:events"
|
|
4
|
+
import {readFileSync} from "node:fs"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import {createConnection as netConnect} from "node:net"
|
|
8
|
+
import {connect as tlsConnect} from "node:tls"
|
|
9
|
+
|
|
10
|
+
import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
|
|
11
|
+
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
|
|
12
|
+
import {SessionSpool} from "../spool/spool-file.js"
|
|
13
|
+
import {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "./virtual-socket.js"
|
|
14
|
+
|
|
15
|
+
const DEFAULT_WINDOW = 256 * 1024
|
|
16
|
+
const DEFAULT_FRAME_BYTES = 16 * 1024
|
|
17
|
+
const DEFAULT_STREAM_BYTES = 4 * 1024 * 1024
|
|
18
|
+
const DEFAULT_CONNECTION_BYTES = 32 * 1024 * 1024
|
|
19
|
+
const DEFAULT_MAX_STREAMS = 4096
|
|
20
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
21
|
+
const DEFAULT_SPOOL_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
22
|
+
const MANIFEST_PAGE_STREAMS = 64
|
|
23
|
+
const MAX_CONTROL_QUEUE_BYTES = 1024 * 1024
|
|
24
|
+
const STABLE_IDENTIFIER = /^[A-Za-z0-9_-]{8,128}$/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} MultiplexedConnectionOptions
|
|
28
|
+
* @property {{host: string, port: number, token: string}} relay - Authenticated relay endpoint.
|
|
29
|
+
* @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string, rejectUnauthorized?: boolean}} [tls] - Outer TLS settings.
|
|
30
|
+
* @property {string} [spoolDirectory] - Temporary replay spool root.
|
|
31
|
+
* @property {number} [maxAgeMs] - Maximum durable stream spool age, default 24 hours.
|
|
32
|
+
* @property {string} [connectionId] - Stable caller-owned identity required for recovery across client objects.
|
|
33
|
+
* @property {number} [initialWindowBytes] - Per-direction flow-control window.
|
|
34
|
+
* @property {number} [maxFrameBytes] - Maximum DATA payload.
|
|
35
|
+
* @property {number} [maxStreamBufferBytes] - Per-stream unacknowledged byte bound.
|
|
36
|
+
* @property {number} [maxConnectionBufferBytes] - Aggregate unacknowledged byte bound.
|
|
37
|
+
* @property {number} [maxStreams] - Concurrent stream bound.
|
|
38
|
+
* @property {number} [reconnectDelayMs] - Delay before the one shared reconnect.
|
|
39
|
+
* @property {number} [maxReconnectAttempts] - Bounded physical reconnect attempts.
|
|
40
|
+
* @property {number} [connectTimeoutMs] - Per-attempt physical/auth timeout.
|
|
41
|
+
* @property {number} [heartbeatIntervalMs] - Connection heartbeat interval, default 30 seconds.
|
|
42
|
+
* @property {number} [heartbeatTimeoutMs] - Missing-PONG reconnect threshold, default 90 seconds.
|
|
43
|
+
* @property {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} [createPhysicalSocket] - Test/custom transport factory.
|
|
44
|
+
* @property {boolean} [continuousReconnect] - Keep a warmed member reconnecting until explicitly closed.
|
|
45
|
+
* @property {{reserveData(bytes: number): void, releaseMemory(bytes: number): void, releaseSpool(bytes: number): void, reserveControl(bytes: number): void, releaseControl(bytes: number): void}} [aggregateBudget] - Optional process-wide set budget.
|
|
46
|
+
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
|
|
47
|
+
* @typedef {object} VirtualConnectionOptions
|
|
48
|
+
* @property {string} [host] - Target host.
|
|
49
|
+
* @property {number} [port] - Target port.
|
|
50
|
+
* @property {{host: string, port: number}} [target] - Explicit target.
|
|
51
|
+
* @property {string} [sessionName] - Human-readable stream name.
|
|
52
|
+
* @property {string} [identifier] - Stable resumable identifier.
|
|
53
|
+
* @property {boolean} [recover] - Recover this stream from its existing durable v3 spool.
|
|
54
|
+
* @property {boolean} [allowHalfOpen] - Duplex half-close behavior.
|
|
55
|
+
* @property {number} [readableHighWaterMark] - Readable buffer threshold.
|
|
56
|
+
* @property {number} [writableHighWaterMark] - Writable buffer threshold.
|
|
57
|
+
* @typedef {{wire: Buffer, type: number, payloadBytes: number}} QueuedFrame
|
|
58
|
+
* @typedef {{sequence: number, data: Buffer}} ReplayChunk
|
|
59
|
+
* @typedef {object} ClientStream
|
|
60
|
+
* @property {number} id - Protocol stream ID.
|
|
61
|
+
* @property {string} identifier - Stable resume ID.
|
|
62
|
+
* @property {string} name - Human name.
|
|
63
|
+
* @property {{host: string, port: number}} target - Target endpoint.
|
|
64
|
+
* @property {VirtualSocket} socket - Public Duplex.
|
|
65
|
+
* @property {"opening" | "open" | "half-closed" | "closed" | "reset"} state - Lifecycle.
|
|
66
|
+
* @property {boolean} opened - Whether OPENED was observed.
|
|
67
|
+
* @property {boolean} openingSent - Whether OPEN was sent in this generation.
|
|
68
|
+
* @property {boolean} localEnded - Local writable EOF.
|
|
69
|
+
* @property {boolean} remoteEnded - Remote readable EOF.
|
|
70
|
+
* @property {boolean} remoteClosed - Relay terminal close.
|
|
71
|
+
* @property {number} sendSequence - Last locally allocated byte sequence.
|
|
72
|
+
* @property {number} sendAck - Last relay acknowledgement.
|
|
73
|
+
* @property {number} receiveSequence - Last contiguous relay byte sequence.
|
|
74
|
+
* @property {number} receiveAck - Last acknowledgement sent to relay.
|
|
75
|
+
* @property {number} sendWindow - Available peer receive window.
|
|
76
|
+
* @property {number} receiveUnackedBytes - Bytes delivered but not acknowledged.
|
|
77
|
+
* @property {number | undefined} pendingReceiveAck - Withheld ACK while readable backpressured.
|
|
78
|
+
* @property {number} bufferedBytes - Retained unacknowledged send bytes.
|
|
79
|
+
* @property {number} spoolReservedBytes - Shared budget retained until durable cleanup succeeds.
|
|
80
|
+
* @property {ReplayChunk[]} replay - Raw unacknowledged bytes.
|
|
81
|
+
* @property {QueuedFrame[]} queue - Fair-scheduler frames.
|
|
82
|
+
* @property {Promise<void>} spoolReady - Spool initialization.
|
|
83
|
+
* @property {Promise<void>} spoolQueue - Serialized append/truncate/remove work.
|
|
84
|
+
* @property {boolean} spoolRemovalQueued - Idempotent terminal cleanup flag.
|
|
85
|
+
* @property {Promise<void> | undefined} spoolRemovalPromise - Observable terminal cleanup result.
|
|
86
|
+
* @property {boolean} spoolCleanupFailureRecorded - Whether cleanup failure was counted.
|
|
87
|
+
* @property {boolean} ownsSpool - Whether terminal cleanup may remove this spool.
|
|
88
|
+
* @property {boolean} recoveredObject - Whether receive state came from a replaced client object.
|
|
89
|
+
* @typedef {object} ClientRecoveryState
|
|
90
|
+
* @property {number} version - Recovery schema version.
|
|
91
|
+
* @property {string} connectionId - Stable physical connection identity.
|
|
92
|
+
* @property {number} generation - Last authenticated generation.
|
|
93
|
+
* @property {number} streamId - Stable wire stream ID.
|
|
94
|
+
* @property {string} identifier - Stable stream identifier.
|
|
95
|
+
* @property {string} name - Human-readable stream name.
|
|
96
|
+
* @property {{host: string, port: number}} target - Authorized target identity.
|
|
97
|
+
* @property {boolean} opened - Whether the target was opened.
|
|
98
|
+
* @property {boolean} openingSent - Whether OPEN may have reached the relay.
|
|
99
|
+
* @property {boolean} localEnded - Whether local EOF was sent.
|
|
100
|
+
* @property {number} sendSequence - Highest durable client byte sequence.
|
|
101
|
+
* @property {number} sendAck - Highest relay-acknowledged client byte sequence.
|
|
102
|
+
* @property {number} receiveSequence - Highest accepted target byte sequence.
|
|
103
|
+
* @property {number} receiveAck - Highest durably acknowledged target byte sequence.
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/** One authenticated physical transport carrying many virtual TCP streams. */
|
|
107
|
+
export class MultiplexedSocketductConnection extends EventEmitter {
|
|
108
|
+
/** @type {MultiplexedConnectionOptions} */
|
|
109
|
+
#options
|
|
110
|
+
|
|
111
|
+
/** @type {Map<number, ClientStream>} */
|
|
112
|
+
#streams = new Map()
|
|
113
|
+
|
|
114
|
+
/** @type {number} */
|
|
115
|
+
#nextStreamId = 1
|
|
116
|
+
|
|
117
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
118
|
+
#physical
|
|
119
|
+
|
|
120
|
+
/** @type {Promise<void> | undefined} */
|
|
121
|
+
#connectPromise
|
|
122
|
+
|
|
123
|
+
/** @type {Promise<void> | undefined} */
|
|
124
|
+
#warmPromise
|
|
125
|
+
|
|
126
|
+
/** @type {(() => void) | undefined} */
|
|
127
|
+
#resolveConnect
|
|
128
|
+
|
|
129
|
+
/** @type {((error: Error) => void) | undefined} */
|
|
130
|
+
#rejectConnect
|
|
131
|
+
|
|
132
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
133
|
+
#reconnectTimer
|
|
134
|
+
|
|
135
|
+
/** @type {WeakMap<import('node:net').Socket, ReturnType<typeof setTimeout>>} */
|
|
136
|
+
#attemptTimers = new WeakMap()
|
|
137
|
+
|
|
138
|
+
/** @type {boolean} */
|
|
139
|
+
#closing = false
|
|
140
|
+
|
|
141
|
+
/** @type {boolean} */
|
|
142
|
+
#warm = false
|
|
143
|
+
|
|
144
|
+
/** @type {"configured" | "connecting" | "ready" | "reconnecting" | "disconnected" | "closed"} */
|
|
145
|
+
#physicalState = "configured"
|
|
146
|
+
|
|
147
|
+
/** @type {boolean} */
|
|
148
|
+
#preserveSpools = false
|
|
149
|
+
|
|
150
|
+
/** @type {boolean} */
|
|
151
|
+
#authenticated = false
|
|
152
|
+
|
|
153
|
+
/** @type {boolean} */
|
|
154
|
+
#hadAuthenticatedGeneration = false
|
|
155
|
+
|
|
156
|
+
/** @type {boolean} */
|
|
157
|
+
#resuming = false
|
|
158
|
+
|
|
159
|
+
/** @type {number} */
|
|
160
|
+
#generation = 0
|
|
161
|
+
|
|
162
|
+
/** @type {number} */
|
|
163
|
+
#reconnectAttempts = 0
|
|
164
|
+
|
|
165
|
+
/** @type {number} */
|
|
166
|
+
#reconnectAttemptTotal = 0
|
|
167
|
+
|
|
168
|
+
/** @type {number} */
|
|
169
|
+
#physicalConnects = 0
|
|
170
|
+
|
|
171
|
+
/** @type {number} */
|
|
172
|
+
#physicalAttempts = 0
|
|
173
|
+
|
|
174
|
+
/** @type {number} */
|
|
175
|
+
#reconnectSuccesses = 0
|
|
176
|
+
|
|
177
|
+
/** @type {number} */
|
|
178
|
+
#resumeFailures = 0
|
|
179
|
+
|
|
180
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
181
|
+
#resumeResults = []
|
|
182
|
+
|
|
183
|
+
/** @type {number} */
|
|
184
|
+
#resumeNextPage = 0
|
|
185
|
+
|
|
186
|
+
/** @type {number} */
|
|
187
|
+
#aggregateBufferedBytes = 0
|
|
188
|
+
|
|
189
|
+
/** @type {number} */
|
|
190
|
+
#aggregateSpoolBytes = 0
|
|
191
|
+
|
|
192
|
+
/** @type {number} */
|
|
193
|
+
#spoolCleanupFailures = 0
|
|
194
|
+
|
|
195
|
+
/** @type {Set<Promise<void>>} */
|
|
196
|
+
#spoolCleanupTasks = new Set()
|
|
197
|
+
|
|
198
|
+
/** @type {Buffer[]} */
|
|
199
|
+
#controlQueue = []
|
|
200
|
+
|
|
201
|
+
/** @type {number} */
|
|
202
|
+
#controlQueuedBytes = 0
|
|
203
|
+
|
|
204
|
+
/** @type {boolean} */
|
|
205
|
+
#pumping = false
|
|
206
|
+
|
|
207
|
+
/** @type {number} */
|
|
208
|
+
#roundRobinCursor = 0
|
|
209
|
+
|
|
210
|
+
/** @type {Promise<void>} */
|
|
211
|
+
#openQueue = Promise.resolve()
|
|
212
|
+
|
|
213
|
+
/** @type {string} */
|
|
214
|
+
#connectionId
|
|
215
|
+
|
|
216
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
217
|
+
#heartbeatTimer
|
|
218
|
+
|
|
219
|
+
/** @type {number} */
|
|
220
|
+
#lastPongAt = 0
|
|
221
|
+
|
|
222
|
+
/** @type {number} */
|
|
223
|
+
#pingSequence = 0
|
|
224
|
+
|
|
225
|
+
/** @type {SessionSpool} */
|
|
226
|
+
#spool
|
|
227
|
+
|
|
228
|
+
/** @type {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} */
|
|
229
|
+
#createPhysicalSocket
|
|
230
|
+
|
|
231
|
+
/** @param {MultiplexedConnectionOptions} options - Connection and safety policy. */
|
|
232
|
+
constructor(options) {
|
|
233
|
+
super()
|
|
234
|
+
validateConnectionOptions(options)
|
|
235
|
+
this.#options = options
|
|
236
|
+
this.#connectionId = options.connectionId ?? randomBytes(24).toString("base64url")
|
|
237
|
+
const spoolDirectory = options.spoolDirectory ?? join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "multiplexed")
|
|
238
|
+
this.#spool = new SessionSpool({rootDir: spoolDirectory, maxBytes: this.#maxStreamBytes(), maxAgeMs: options.maxAgeMs ?? DEFAULT_SPOOL_MAX_AGE_MS})
|
|
239
|
+
this.#createPhysicalSocket = options.createPhysicalSocket ?? createPhysicalSocket
|
|
240
|
+
this.createConnection = this.createConnection.bind(this)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Warms and authenticates this member without allocating a target stream.
|
|
245
|
+
* Concurrent calls share the same physical connection promise.
|
|
246
|
+
* @returns {Promise<void>} Resolves when the member becomes ready.
|
|
247
|
+
*/
|
|
248
|
+
connect() {
|
|
249
|
+
if (this.#closing) return Promise.reject(Object.assign(new Error("Multiplexed Socketduct connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}))
|
|
250
|
+
if (this.#warmPromise !== undefined) return this.#warmPromise
|
|
251
|
+
this.#warm = true
|
|
252
|
+
this.#warmPromise = Promise.all([...this.#streams.values()].map((stream) => stream.spoolReady)).then(() => this.#ensureConnected())
|
|
253
|
+
return this.#warmPromise
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Allocates a virtual stream without allocating another physical transport.
|
|
258
|
+
* @param {VirtualConnectionOptions} options - Target/socket options.
|
|
259
|
+
* @param {(error: Error | null, socket: VirtualSocket) => void} [callback] - Node-style optional callback.
|
|
260
|
+
* @returns {VirtualSocket} Socket-shaped Duplex.
|
|
261
|
+
*/
|
|
262
|
+
createConnection(options, callback) {
|
|
263
|
+
return this.#createConnection(options, callback, true)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Registers durable recovery state without starting a physical attempt.
|
|
268
|
+
* @param {VirtualConnectionOptions} options - Recovery options.
|
|
269
|
+
* @param {(error: Error | null, socket: VirtualSocket) => void} [callback] - Node-style optional callback.
|
|
270
|
+
* @returns {VirtualSocket} Cold registered socket.
|
|
271
|
+
*/
|
|
272
|
+
registerRecoveredConnection(options, callback) {
|
|
273
|
+
if (options.recover !== true) throw new TypeError("registerRecoveredConnection requires recover: true")
|
|
274
|
+
return this.#createConnection(options, callback, false)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {VirtualConnectionOptions} options - Target/socket options.
|
|
279
|
+
* @param {((error: Error | null, socket: VirtualSocket) => void) | undefined} callback - Node-style optional callback.
|
|
280
|
+
* @param {boolean} connectOnReady - Whether creation should start the physical connection.
|
|
281
|
+
* @returns {VirtualSocket} Socket-shaped Duplex.
|
|
282
|
+
*/
|
|
283
|
+
#createConnection(options, callback, connectOnReady) {
|
|
284
|
+
if (this.#closing) throw new Error("Multiplexed Socketduct connection is closed")
|
|
285
|
+
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum virtual stream count exceeded")
|
|
286
|
+
const target = normalizeTarget(options)
|
|
287
|
+
const identifier = options.identifier ?? randomBytes(24).toString("base64url")
|
|
288
|
+
if (!STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Virtual stream identifier must be 8-128 URL-safe opaque characters")
|
|
289
|
+
const recovery = options.recover === true
|
|
290
|
+
? validateRecoveryState(this.#spool.readRecoveryStateSync(identifier, "edge-v3"), {
|
|
291
|
+
connectionId: this.#connectionId,
|
|
292
|
+
identifier,
|
|
293
|
+
target,
|
|
294
|
+
...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
|
|
295
|
+
})
|
|
296
|
+
: undefined
|
|
297
|
+
if (recovery !== undefined && this.#physicalAttempts > 0) throw new Error("Recovered streams must be registered before the replacement physical connection starts")
|
|
298
|
+
const id = recovery?.streamId ?? this.#allocateStreamId()
|
|
299
|
+
if (this.#streams.has(id)) throw new Error(`V3 stream ID ${id} is already active`)
|
|
300
|
+
if (recovery !== undefined) {
|
|
301
|
+
this.#nextStreamId = Math.max(this.#nextStreamId, id + 2)
|
|
302
|
+
this.#generation = Math.max(this.#generation, recovery.generation)
|
|
303
|
+
this.#hadAuthenticatedGeneration = true
|
|
304
|
+
}
|
|
305
|
+
const name = options.sessionName ?? recovery?.name ?? `socketduct-v3-${id}`
|
|
306
|
+
if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Virtual stream name must be 1-256 characters")
|
|
307
|
+
const socketOptions = {
|
|
308
|
+
...target,
|
|
309
|
+
...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
|
|
310
|
+
...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
|
|
311
|
+
...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
|
|
312
|
+
}
|
|
313
|
+
const socket = new VirtualSocket(this, id, socketOptions)
|
|
314
|
+
const stream = /** @type {ClientStream} */ ({
|
|
315
|
+
id,
|
|
316
|
+
identifier,
|
|
317
|
+
name,
|
|
318
|
+
target,
|
|
319
|
+
socket,
|
|
320
|
+
state: recovery?.localEnded === true ? "half-closed" : (recovery?.opened === true ? "open" : "opening"),
|
|
321
|
+
opened: recovery?.opened ?? false,
|
|
322
|
+
openingSent: recovery?.openingSent ?? false,
|
|
323
|
+
localEnded: recovery?.localEnded ?? false,
|
|
324
|
+
remoteEnded: false,
|
|
325
|
+
remoteClosed: false,
|
|
326
|
+
sendSequence: recovery?.sendSequence ?? 0,
|
|
327
|
+
sendAck: recovery?.sendAck ?? 0,
|
|
328
|
+
// A replacement VirtualSocket cannot inherit bytes buffered only by the
|
|
329
|
+
// previous object; restart delivery at the durable acknowledgement.
|
|
330
|
+
receiveSequence: recovery?.receiveAck ?? 0,
|
|
331
|
+
receiveAck: recovery?.receiveAck ?? 0,
|
|
332
|
+
sendWindow: this.#initialWindow(),
|
|
333
|
+
receiveUnackedBytes: 0,
|
|
334
|
+
pendingReceiveAck: undefined,
|
|
335
|
+
bufferedBytes: 0,
|
|
336
|
+
spoolReservedBytes: 0,
|
|
337
|
+
replay: [],
|
|
338
|
+
queue: [],
|
|
339
|
+
spoolReady: Promise.resolve(),
|
|
340
|
+
spoolQueue: Promise.resolve(),
|
|
341
|
+
spoolRemovalQueued: false,
|
|
342
|
+
spoolRemovalPromise: undefined,
|
|
343
|
+
spoolCleanupFailureRecorded: false,
|
|
344
|
+
ownsSpool: recovery !== undefined,
|
|
345
|
+
recoveredObject: recovery !== undefined
|
|
346
|
+
})
|
|
347
|
+
stream.spoolReady = recovery === undefined
|
|
348
|
+
? this.#spool.createNew({sessionId: identifier, ownerId: "edge-v3"})
|
|
349
|
+
.then(() => {
|
|
350
|
+
stream.ownsSpool = true
|
|
351
|
+
return this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream))
|
|
352
|
+
})
|
|
353
|
+
: this.#spool.readChunksAfter(identifier, stream.sendAck).then((chunks) => {
|
|
354
|
+
validateRecoveredChunks(stream, chunks, this.#maxStreamBytes())
|
|
355
|
+
if (this.#streams.get(stream.id) !== stream) return
|
|
356
|
+
const bufferedBytes = chunks.reduce((sum, chunk) => sum + chunk.data.length, 0)
|
|
357
|
+
if (this.#aggregateBufferedBytes + bufferedBytes > this.#maxConnectionBytes()) throw new Error("Recovered Socketduct data exceeds the connection buffer limit")
|
|
358
|
+
this.#options.aggregateBudget?.reserveData(bufferedBytes)
|
|
359
|
+
stream.replay = chunks
|
|
360
|
+
stream.bufferedBytes = bufferedBytes
|
|
361
|
+
stream.spoolReservedBytes = bufferedBytes
|
|
362
|
+
this.#aggregateBufferedBytes += bufferedBytes
|
|
363
|
+
this.#aggregateSpoolBytes += bufferedBytes
|
|
364
|
+
})
|
|
365
|
+
stream.spoolQueue = stream.spoolReady.catch(() => {})
|
|
366
|
+
this.#streams.set(id, stream)
|
|
367
|
+
logDiagnostic(this.#options.logger, "virtual_stream.opening", {
|
|
368
|
+
streamId: id,
|
|
369
|
+
generation: this.#generation,
|
|
370
|
+
recovered: recovery !== undefined
|
|
371
|
+
})
|
|
372
|
+
if (callback !== undefined) installConnectCallback(socket, callback)
|
|
373
|
+
if (connectOnReady) {
|
|
374
|
+
stream.spoolReady
|
|
375
|
+
.then(() => this.#streams.get(stream.id) === stream && !stream.socket.destroyed ? this.#ensureConnected() : undefined)
|
|
376
|
+
.then(() => this.#openStream(stream))
|
|
377
|
+
.catch((error) => socket._receiveError(asError(error)))
|
|
378
|
+
} else {
|
|
379
|
+
stream.spoolReady.catch((error) => socket._receiveError(asError(error)))
|
|
380
|
+
}
|
|
381
|
+
return socket
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** @returns {{physicalState: string, connectionGeneration: number, virtualStreams: number, streamStates: Record<string, number>, queuedBytes: number, dataQueuedBytes: number, controlQueuedBytes: number, spoolBytes: number, spoolCleanupFailures: number, physicalConnects: number, reconnectAttempts: number, reconnectSuccesses: number, resumeFailures: number}} Privacy-safe status. */
|
|
385
|
+
status() {
|
|
386
|
+
/** @type {Record<string, number>} */
|
|
387
|
+
const streamStates = {}
|
|
388
|
+
for (const stream of this.#streams.values()) {
|
|
389
|
+
streamStates[stream.state] = (streamStates[stream.state] ?? 0) + 1
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
physicalState: this.#physicalState,
|
|
393
|
+
connectionGeneration: this.#generation,
|
|
394
|
+
virtualStreams: this.#streams.size,
|
|
395
|
+
streamStates,
|
|
396
|
+
queuedBytes: this.#aggregateBufferedBytes + this.#controlQueuedBytes,
|
|
397
|
+
dataQueuedBytes: this.#aggregateBufferedBytes,
|
|
398
|
+
controlQueuedBytes: this.#controlQueuedBytes,
|
|
399
|
+
spoolBytes: this.#aggregateSpoolBytes,
|
|
400
|
+
spoolCleanupFailures: this.#spoolCleanupFailures,
|
|
401
|
+
physicalConnects: this.#physicalConnects,
|
|
402
|
+
reconnectAttempts: this.#reconnectAttemptTotal,
|
|
403
|
+
reconnectSuccesses: this.#reconnectSuccesses,
|
|
404
|
+
resumeFailures: this.#resumeFailures
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** @returns {{address?: string, port?: number}} Current physical local address. */
|
|
409
|
+
physicalAddress() {
|
|
410
|
+
const address = this.#physical?.localAddress
|
|
411
|
+
const port = this.#physical?.localPort
|
|
412
|
+
return {
|
|
413
|
+
...(address === undefined ? {} : {address}),
|
|
414
|
+
...(port === undefined ? {} : {port})
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** @returns {this} */
|
|
419
|
+
ref() {
|
|
420
|
+
this.#physical?.ref()
|
|
421
|
+
this.#reconnectTimer?.ref?.()
|
|
422
|
+
return this
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** @returns {this} */
|
|
426
|
+
unref() {
|
|
427
|
+
this.#physical?.unref()
|
|
428
|
+
this.#reconnectTimer?.unref?.()
|
|
429
|
+
return this
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Gracefully closes all virtual streams and the shared transport. @returns {Promise<void>} */
|
|
433
|
+
async close() {
|
|
434
|
+
if (this.#closing) return
|
|
435
|
+
this.#closing = true
|
|
436
|
+
this.#setPhysicalState("closed")
|
|
437
|
+
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
438
|
+
this.#reconnectTimer = undefined
|
|
439
|
+
this.#stopHeartbeat()
|
|
440
|
+
this.#clearControlQueue()
|
|
441
|
+
if (this.#physical !== undefined && !this.#physical.destroyed && this.#authenticated) {
|
|
442
|
+
try {
|
|
443
|
+
this.#physical.end(encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({reason: "client_close"})))
|
|
444
|
+
} catch {
|
|
445
|
+
this.#physical.destroy()
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** @type {Error[]} */
|
|
449
|
+
const cleanupErrors = []
|
|
450
|
+
for (const stream of [...this.#streams.values()]) {
|
|
451
|
+
stream.remoteClosed = true
|
|
452
|
+
if (!stream.socket.destroyed) stream.socket.destroy()
|
|
453
|
+
if (stream.ownsSpool) {
|
|
454
|
+
try {
|
|
455
|
+
await this.#removeSpool(stream)
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const cleanupError = asError(error)
|
|
458
|
+
this.#handleSpoolCleanupFailure(stream, cleanupError)
|
|
459
|
+
cleanupErrors.push(cleanupError)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const pendingResults = await Promise.allSettled([...this.#spoolCleanupTasks])
|
|
464
|
+
for (const result of pendingResults) {
|
|
465
|
+
if (result.status === "rejected") {
|
|
466
|
+
const cleanupError = asError(result.reason)
|
|
467
|
+
if (!cleanupErrors.includes(cleanupError)) cleanupErrors.push(cleanupError)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
this.#streams.clear()
|
|
471
|
+
this.#physical?.destroy()
|
|
472
|
+
this.#physical = undefined
|
|
473
|
+
this.#authenticated = false
|
|
474
|
+
this.#rejectConnect?.(new Error("Multiplexed Socketduct connection closed"))
|
|
475
|
+
this.#settleConnect()
|
|
476
|
+
if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Socketduct durable spool cleanup failed")
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Detaches the physical transport while preserving active stream spools for
|
|
481
|
+
* a new connection object using the same caller-provided connectionId.
|
|
482
|
+
* @returns {Promise<void>} Resolves after recovery state is durable.
|
|
483
|
+
*/
|
|
484
|
+
async detach() {
|
|
485
|
+
if (this.#closing) return
|
|
486
|
+
if (this.#options.connectionId === undefined) throw new Error("detach() requires an explicit stable connectionId")
|
|
487
|
+
this.#preserveSpools = true
|
|
488
|
+
this.#closing = true
|
|
489
|
+
this.#setPhysicalState("closed")
|
|
490
|
+
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
491
|
+
this.#reconnectTimer = undefined
|
|
492
|
+
this.#stopHeartbeat()
|
|
493
|
+
try {
|
|
494
|
+
await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
|
|
495
|
+
if (this.#streams.size === 0 && this.#physical !== undefined && !this.#physical.destroyed && this.#authenticated) {
|
|
496
|
+
const physical = this.#physical
|
|
497
|
+
await new Promise((resolve) => {
|
|
498
|
+
physical.once("close", resolve)
|
|
499
|
+
physical.end(encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({reason: "client_detach_empty"})))
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
} finally {
|
|
503
|
+
this.#physical?.destroy()
|
|
504
|
+
this.#physical = undefined
|
|
505
|
+
this.#authenticated = false
|
|
506
|
+
this.#rejectConnect?.(new SocketductSessionLostError("Socketduct connection detached for durable recovery", "CONNECTION_DETACHED"))
|
|
507
|
+
this.#settleConnect()
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* @param {number} streamId - Stream. @param {Buffer} data - Bytes. @param {(error?: Error | null) => void} callback - Completion. @returns {void}
|
|
513
|
+
* @param data
|
|
514
|
+
* @param callback
|
|
515
|
+
*/
|
|
516
|
+
_writeStream(streamId, data, callback) {
|
|
517
|
+
const stream = this.#streams.get(streamId)
|
|
518
|
+
if (this.#closing || stream === undefined || stream.state === "closed" || stream.state === "reset" || stream.localEnded) {
|
|
519
|
+
callback(new SocketductStreamError("Virtual stream is not writable", "ERR_STREAM_DESTROYED"))
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
if (stream.sendSequence > Number.MAX_SAFE_INTEGER - data.length) {
|
|
523
|
+
callback(new SocketductStreamError("Virtual stream sequence space exhausted", "SEQUENCE_EXHAUSTED"))
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
if (stream.bufferedBytes + data.length > this.#maxStreamBytes() || this.#aggregateBufferedBytes + data.length > this.#maxConnectionBytes()) {
|
|
527
|
+
const error = new SocketductStreamError("Socketduct stream buffer limit exceeded", "SOCKETDUCT_BUFFER_LIMIT")
|
|
528
|
+
callback(error)
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
this.#options.aggregateBudget?.reserveData(data.length)
|
|
533
|
+
} catch (error) {
|
|
534
|
+
callback(asError(error))
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
/** @type {ReplayChunk[]} */
|
|
538
|
+
const chunks = []
|
|
539
|
+
for (let offset = 0; offset < data.length; offset += this.#maxFrameBytes()) {
|
|
540
|
+
const part = Buffer.from(data.subarray(offset, Math.min(data.length, offset + this.#maxFrameBytes())))
|
|
541
|
+
stream.sendSequence += part.length
|
|
542
|
+
chunks.push({sequence: stream.sendSequence, data: part})
|
|
543
|
+
}
|
|
544
|
+
stream.replay.push(...chunks)
|
|
545
|
+
stream.bufferedBytes += data.length
|
|
546
|
+
stream.spoolReservedBytes += data.length
|
|
547
|
+
this.#aggregateBufferedBytes += data.length
|
|
548
|
+
this.#aggregateSpoolBytes += data.length
|
|
549
|
+
const persist = async () => {
|
|
550
|
+
await this.#queueSpoolOperation(stream, async () => {
|
|
551
|
+
for (const chunk of chunks) {
|
|
552
|
+
await this.#spool.appendChunk({sessionId: stream.identifier, ownerId: "edge-v3", sequence: chunk.sequence, data: chunk.data})
|
|
553
|
+
}
|
|
554
|
+
await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
|
|
555
|
+
})
|
|
556
|
+
if (this.#streams.get(stream.id) === stream) for (const chunk of chunks) this.#queueData(stream, chunk)
|
|
557
|
+
}
|
|
558
|
+
persist().then(() => callback(), (error) => callback(asError(error)))
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* @param {number} streamId - Stream. @param {(error?: Error | null) => void} callback - Completion. @returns {void}
|
|
563
|
+
* @param callback
|
|
564
|
+
*/
|
|
565
|
+
_endStream(streamId, callback) {
|
|
566
|
+
const stream = this.#streams.get(streamId)
|
|
567
|
+
if (stream === undefined || stream.localEnded) {
|
|
568
|
+
callback()
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
stream.localEnded = true
|
|
572
|
+
stream.state = "half-closed"
|
|
573
|
+
this.#persistStreamState(stream).then(() => {
|
|
574
|
+
this.#queueStreamControl(stream, V3FrameType.END, stream.sendSequence)
|
|
575
|
+
callback()
|
|
576
|
+
}, (error) => callback(asError(error)))
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* @param {number} streamId - Stream. @param {Error | null} error - Cause. @returns {void}
|
|
581
|
+
* @param error
|
|
582
|
+
*/
|
|
583
|
+
_destroyStream(streamId, error) {
|
|
584
|
+
const stream = this.#streams.get(streamId)
|
|
585
|
+
if (stream === undefined) return
|
|
586
|
+
this.#streams.delete(streamId)
|
|
587
|
+
this.#aggregateBufferedBytes = Math.max(0, this.#aggregateBufferedBytes - stream.bufferedBytes)
|
|
588
|
+
this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
|
|
589
|
+
stream.state = error === null ? "closed" : "reset"
|
|
590
|
+
logDiagnostic(this.#options.logger, error === null ? "virtual_stream.closed" : "virtual_stream.error", {
|
|
591
|
+
streamId,
|
|
592
|
+
generation: this.#generation,
|
|
593
|
+
...(error === null ? {} : errorDiagnostics(error))
|
|
594
|
+
})
|
|
595
|
+
if (!stream.remoteClosed && this.#authenticated && !this.#resuming) {
|
|
596
|
+
this.#queueControlFrame(V3FrameType.RESET, streamId, 0, {
|
|
597
|
+
code: error instanceof SocketductStreamError ? error.code : "CLIENT_CANCELLED"
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
if (!this.#preserveSpools) {
|
|
601
|
+
this.#trackSpoolCleanup(stream)
|
|
602
|
+
}
|
|
603
|
+
if (this.#streams.size === 0 && !this.#authenticated && this.#physical !== undefined) this.#physical.destroy()
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** @param {number} streamId - Stream. @returns {void} */
|
|
607
|
+
_resumeReadable(streamId) {
|
|
608
|
+
const stream = this.#streams.get(streamId)
|
|
609
|
+
if (stream?.pendingReceiveAck === undefined) return
|
|
610
|
+
const ack = stream.pendingReceiveAck
|
|
611
|
+
stream.pendingReceiveAck = undefined
|
|
612
|
+
this.#ackReceived(stream, ack)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** @returns {number} */
|
|
616
|
+
#allocateStreamId() {
|
|
617
|
+
const id = this.#nextStreamId
|
|
618
|
+
if (id > 0xffff_fffd) throw new Error("V3 stream ID space exhausted for this connection generation")
|
|
619
|
+
this.#nextStreamId += 2
|
|
620
|
+
return id
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** @returns {Promise<void>} */
|
|
624
|
+
#ensureConnected() {
|
|
625
|
+
if (this.#authenticated && !this.#resuming && this.#physical !== undefined && !this.#physical.destroyed) return Promise.resolve()
|
|
626
|
+
if (this.#connectPromise !== undefined) return this.#connectPromise
|
|
627
|
+
this.#connectPromise = new Promise((resolve, reject) => {
|
|
628
|
+
this.#resolveConnect = resolve
|
|
629
|
+
this.#rejectConnect = reject
|
|
630
|
+
})
|
|
631
|
+
this.#connectPhysical()
|
|
632
|
+
return this.#connectPromise
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** @returns {void} */
|
|
636
|
+
#connectPhysical() {
|
|
637
|
+
if (this.#closing) return
|
|
638
|
+
const wasConnected = this.#hadAuthenticatedGeneration
|
|
639
|
+
const retryAttempt = this.#physicalAttempts > 0
|
|
640
|
+
logDiagnostic(this.#options.logger, "transport.connecting", {
|
|
641
|
+
generation: this.#generation,
|
|
642
|
+
attempt: this.#physicalAttempts + 1,
|
|
643
|
+
reconnect: wasConnected || retryAttempt
|
|
644
|
+
})
|
|
645
|
+
this.#setPhysicalState(wasConnected || retryAttempt ? "reconnecting" : "connecting")
|
|
646
|
+
this.#physicalAttempts += 1
|
|
647
|
+
if (retryAttempt) {
|
|
648
|
+
this.#reconnectAttempts += 1
|
|
649
|
+
this.#reconnectAttemptTotal += 1
|
|
650
|
+
}
|
|
651
|
+
let physical
|
|
652
|
+
try {
|
|
653
|
+
physical = this.#createPhysicalSocket({
|
|
654
|
+
host: this.#options.relay.host,
|
|
655
|
+
port: this.#options.relay.port,
|
|
656
|
+
...(this.#options.tls === undefined ? {} : {tls: resolveTls(this.#options.tls)})
|
|
657
|
+
})
|
|
658
|
+
} catch (error) {
|
|
659
|
+
this.#failAttempt(asError(error), true)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
this.#physical = physical
|
|
663
|
+
this.#authenticated = false
|
|
664
|
+
this.#resuming = wasConnected
|
|
665
|
+
const decoderLimit = Math.max(64 * 1024, this.#maxFrameBytes())
|
|
666
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: decoderLimit, maxBufferedBytes: decoderLimit * 2})
|
|
667
|
+
const readyEvent = this.#options.tls === undefined ? "connect" : "secureConnect"
|
|
668
|
+
/** @type {Error & {code?: string} | undefined} */
|
|
669
|
+
let attemptError
|
|
670
|
+
const timer = setTimeout(() => {
|
|
671
|
+
attemptError = Object.assign(new Error("Socketduct v3 connect/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})
|
|
672
|
+
physical.destroy(attemptError)
|
|
673
|
+
}, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
674
|
+
this.#attemptTimers.set(physical, timer)
|
|
675
|
+
timer.unref?.()
|
|
676
|
+
const onReady = () => {
|
|
677
|
+
if (this.#physical !== physical || this.#closing) return
|
|
678
|
+
this.#generation += 1
|
|
679
|
+
this.#physicalConnects += 1
|
|
680
|
+
const generation = this.#generation
|
|
681
|
+
// Persist the attempted generation before AUTH can reach the relay. A
|
|
682
|
+
// crash after AUTH but before AUTH_OK can then advance past it safely.
|
|
683
|
+
void Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream))).then(() => {
|
|
684
|
+
if (this.#physical !== physical || physical.destroyed || this.#closing) return
|
|
685
|
+
const auth = encodeV3Control({
|
|
686
|
+
token: this.#options.relay.token,
|
|
687
|
+
versions: [3],
|
|
688
|
+
connectionId: this.#connectionId,
|
|
689
|
+
generation,
|
|
690
|
+
initialWindow: this.#initialWindow(),
|
|
691
|
+
compression: []
|
|
692
|
+
})
|
|
693
|
+
physical.write(encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, auth))
|
|
694
|
+
}, (error) => physical.destroy(asError(error)))
|
|
695
|
+
}
|
|
696
|
+
physical.once(readyEvent, onReady)
|
|
697
|
+
physical.on("data", (chunk) => {
|
|
698
|
+
let frames
|
|
699
|
+
try {
|
|
700
|
+
frames = decoder.push(chunk)
|
|
701
|
+
} catch (error) {
|
|
702
|
+
physical.destroy(asError(error))
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
for (const frame of frames) {
|
|
706
|
+
try {
|
|
707
|
+
this.#handleFrame(frame, physical)
|
|
708
|
+
} catch (error) {
|
|
709
|
+
physical.destroy(asError(error))
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
})
|
|
713
|
+
physical.once("error", (error) => {
|
|
714
|
+
attemptError = error
|
|
715
|
+
if (isFatalTransportError(error)) this.#failAll(error)
|
|
716
|
+
})
|
|
717
|
+
physical.once("close", () => {
|
|
718
|
+
this.#clearAttemptTimeout(physical)
|
|
719
|
+
if (this.#physical !== physical) return
|
|
720
|
+
this.#physical = undefined
|
|
721
|
+
this.#authenticated = false
|
|
722
|
+
this.#stopHeartbeat()
|
|
723
|
+
this.#clearControlQueue()
|
|
724
|
+
this.#roundRobinCursor = 0
|
|
725
|
+
for (const stream of this.#streams.values()) {
|
|
726
|
+
stream.queue = []
|
|
727
|
+
}
|
|
728
|
+
if (this.#closing) return
|
|
729
|
+
logDiagnostic(this.#options.logger, "transport.disconnected", {
|
|
730
|
+
generation: this.#generation,
|
|
731
|
+
virtualStreams: this.#streams.size,
|
|
732
|
+
...errorDiagnostics(attemptError, "TRANSPORT_CLOSED")
|
|
733
|
+
})
|
|
734
|
+
if (this.#streams.size === 0 && !this.#warm) {
|
|
735
|
+
this.#setPhysicalState("disconnected")
|
|
736
|
+
this.#rejectConnect?.(attemptError ?? new Error("Socketduct physical attempt closed"))
|
|
737
|
+
this.#settleConnect()
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
if (attemptError?.code === "SOCKETDUCT_AUTH_TIMEOUT" && !this.#hadAuthenticatedGeneration && this.#options.continuousReconnect !== true) {
|
|
741
|
+
this.#setPhysicalState("disconnected")
|
|
742
|
+
this.#rejectConnect?.(attemptError)
|
|
743
|
+
this.#settleConnect()
|
|
744
|
+
for (const stream of [...this.#streams.values()]) stream.socket._receiveError(attemptError)
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
this.#scheduleReconnect()
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* @param {import('../protocol/v3-frame.js').V3Frame} frame - Decoded frame. @param {import('node:net').Socket} physical - Source. @returns {void}
|
|
753
|
+
* @param physical
|
|
754
|
+
*/
|
|
755
|
+
#handleFrame(frame, physical) {
|
|
756
|
+
if (this.#physical !== physical) return
|
|
757
|
+
if (frame.type === V3FrameType.AUTH_OK) {
|
|
758
|
+
// Attempt timeout ownership extends through AUTH_OK.
|
|
759
|
+
this.#clearAttemptTimeout(physical)
|
|
760
|
+
if (this.#authenticated) throw new Error("Relay repeated V3 authentication")
|
|
761
|
+
const control = decodeV3Control(frame.payload)
|
|
762
|
+
if (control.version !== 3 || control.connectionId !== this.#connectionId || control.generation !== this.#generation || typeof control.resumed !== "boolean") throw new Error("Relay did not negotiate Socketduct protocol v3")
|
|
763
|
+
if (this.#resuming && control.resumed === false) {
|
|
764
|
+
const hasResumeState = [...this.#streams.values()].some((stream) => stream.opened || stream.openingSent)
|
|
765
|
+
if (hasResumeState) {
|
|
766
|
+
this.#failAll(new SocketductSessionLostError("Relay lost the multiplexed connection resume state", "CONNECTION_RESUME_LOST"))
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
this.#resuming = false
|
|
770
|
+
} else if (!this.#resuming && control.resumed === true) {
|
|
771
|
+
// The previous AUTH may have reached the relay even when AUTH_OK did not
|
|
772
|
+
// reach this client. Complete an empty/partial manifest safely.
|
|
773
|
+
this.#resuming = true
|
|
774
|
+
}
|
|
775
|
+
this.#hadAuthenticatedGeneration = true
|
|
776
|
+
this.#authenticated = true
|
|
777
|
+
void this.#finishAuthenticatedGeneration(physical).catch((error) => physical.destroy(asError(error)))
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
|
|
781
|
+
const detail = decodeV3Control(frame.payload)
|
|
782
|
+
const code = typeof detail.code === "string" ? detail.code : "AUTH_FAILED"
|
|
783
|
+
this.#failAll(new SocketductSessionLostError("Socketduct physical connection was rejected", code))
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
if (!this.#authenticated) throw new Error("V3 frame received before authentication")
|
|
787
|
+
if (frame.type === V3FrameType.PING) {
|
|
788
|
+
this.#queueControlFrame(V3FrameType.PONG, 0, frame.sequence, {})
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
if (frame.type === V3FrameType.PONG) {
|
|
792
|
+
this.#lastPongAt = Date.now()
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
if (frame.type === V3FrameType.RESUMED) {
|
|
796
|
+
if (!this.#resuming) throw new Error("Unexpected V3 resume response")
|
|
797
|
+
const manifest = decodeV3Control(frame.payload)
|
|
798
|
+
if (manifest.page !== this.#resumeNextPage || !Array.isArray(manifest.streams)) throw new Error("Invalid paginated V3 resume response")
|
|
799
|
+
this.#resumeNextPage += 1
|
|
800
|
+
this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
|
|
801
|
+
if (manifest.final !== true) return
|
|
802
|
+
for (const item of this.#resumeResults) this.#applyResumeResult(item)
|
|
803
|
+
this.#resumeResults = []
|
|
804
|
+
this.#resuming = false
|
|
805
|
+
this.#reconnectSuccesses += 1
|
|
806
|
+
this.#roundRobinCursor = 0
|
|
807
|
+
for (const stream of this.#streams.values()) {
|
|
808
|
+
if (!stream.opened) continue
|
|
809
|
+
stream.queue = stream.queue.filter((entry) => entry.type !== V3FrameType.DATA)
|
|
810
|
+
for (const chunk of stream.replay) {
|
|
811
|
+
if (chunk.sequence > stream.sendAck) this.#queueData(stream, chunk)
|
|
812
|
+
}
|
|
813
|
+
if (stream.localEnded) this.#queueStreamControl(stream, V3FrameType.END, stream.sendSequence)
|
|
814
|
+
}
|
|
815
|
+
this.#completeConnection()
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
const stream = this.#streams.get(frame.streamId)
|
|
819
|
+
if (stream === undefined) {
|
|
820
|
+
if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) this.#queueControlFrame(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
if (frame.type === V3FrameType.OPENED) {
|
|
824
|
+
const detail = decodeV3Control(frame.payload)
|
|
825
|
+
if (detail.identifier !== stream.identifier) throw new Error("Relay opened response identifier mismatch")
|
|
826
|
+
stream.opened = true
|
|
827
|
+
stream.state = "open"
|
|
828
|
+
logDiagnostic(this.#options.logger, "virtual_stream.opened", {
|
|
829
|
+
streamId: stream.id,
|
|
830
|
+
generation: this.#generation,
|
|
831
|
+
recovered: false
|
|
832
|
+
})
|
|
833
|
+
stream.sendWindow = typeof detail.initialWindow === "number" ? Math.min(detail.initialWindow, this.#initialWindow()) : this.#initialWindow()
|
|
834
|
+
void this.#persistStreamState(stream).then(() => {
|
|
835
|
+
if (this.#streams.get(stream.id) !== stream) return
|
|
836
|
+
stream.socket._markConnected()
|
|
837
|
+
if (!stream.queue.some((entry) => entry.type === V3FrameType.DATA)) {
|
|
838
|
+
for (const chunk of stream.replay) if (chunk.sequence > stream.sendAck) this.#queueData(stream, chunk)
|
|
839
|
+
}
|
|
840
|
+
this.#schedulePump()
|
|
841
|
+
}, (error) => stream.socket._receiveError(asError(error)))
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
if (frame.type === V3FrameType.DATA) {
|
|
845
|
+
if (frame.flags !== 0) throw new Error("V3 DATA compression was not negotiated")
|
|
846
|
+
const expected = stream.receiveSequence + frame.payload.length
|
|
847
|
+
if (frame.sequence !== expected || frame.payload.length > this.#maxFrameBytes()) throw new Error("Non-contiguous or oversized V3 stream data")
|
|
848
|
+
stream.receiveSequence = frame.sequence
|
|
849
|
+
stream.receiveUnackedBytes += frame.payload.length
|
|
850
|
+
if (stream.receiveUnackedBytes > this.#initialWindow()) throw new Error("V3 peer exceeded receive window")
|
|
851
|
+
const canContinue = stream.socket._receive(frame.payload)
|
|
852
|
+
if (canContinue) this.#ackReceived(stream, frame.sequence)
|
|
853
|
+
else stream.pendingReceiveAck = frame.sequence
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
if (frame.type === V3FrameType.ACK) {
|
|
857
|
+
this.#applyAck(stream, frame.sequence)
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
if (frame.type === V3FrameType.WINDOW) {
|
|
861
|
+
if (frame.sequence > this.#initialWindow()) throw new Error("Invalid V3 window update")
|
|
862
|
+
stream.sendWindow = Math.min(this.#initialWindow(), stream.sendWindow + frame.sequence)
|
|
863
|
+
this.#schedulePump()
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
if (frame.type === V3FrameType.END) {
|
|
867
|
+
stream.remoteEnded = true
|
|
868
|
+
stream.socket._receiveEnd()
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
if (frame.type === V3FrameType.CLOSE) {
|
|
872
|
+
// CLOSE is terminal but acknowledged symmetrically. Keep the reply in the
|
|
873
|
+
// connection control queue so VirtualSocket auto-destruction cannot drop it.
|
|
874
|
+
this.#queueControlFrame(V3FrameType.CLOSE, stream.id, frame.sequence, {})
|
|
875
|
+
stream.remoteClosed = true
|
|
876
|
+
stream.state = "closed"
|
|
877
|
+
stream.socket._receiveEnd()
|
|
878
|
+
return
|
|
879
|
+
}
|
|
880
|
+
if (frame.type === V3FrameType.RESET) {
|
|
881
|
+
const detail = decodeV3Control(frame.payload)
|
|
882
|
+
const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
|
|
883
|
+
stream.remoteClosed = true
|
|
884
|
+
stream.state = "reset"
|
|
885
|
+
const lost = code.startsWith("RESUME_") || code === "SESSION_LOST" || code.includes("UNCERTAIN")
|
|
886
|
+
stream.socket._receiveError(lost
|
|
887
|
+
? new SocketductSessionLostError("Virtual stream could not be resumed", code)
|
|
888
|
+
: new SocketductStreamError("Virtual stream was reset by relay", code))
|
|
889
|
+
return
|
|
890
|
+
}
|
|
891
|
+
throw new Error(`Unsupported V3 frame type from relay: ${frame.type}`)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Makes the authenticated generation durable before opening or resuming.
|
|
896
|
+
* @param {import('node:net').Socket} physical - Attempt owner.
|
|
897
|
+
* @returns {Promise<void>} Completion.
|
|
898
|
+
*/
|
|
899
|
+
async #finishAuthenticatedGeneration(physical) {
|
|
900
|
+
await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
|
|
901
|
+
if (this.#physical !== physical || physical.destroyed || !this.#authenticated) return
|
|
902
|
+
if (!this.#resuming) {
|
|
903
|
+
this.#completeConnection()
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
const streams = [...this.#streams.values()].filter((stream) => stream.opened || stream.openingSent).map((stream) => ({
|
|
907
|
+
streamId: stream.id,
|
|
908
|
+
identifier: stream.identifier,
|
|
909
|
+
clientToTargetAck: stream.sendAck,
|
|
910
|
+
// A surviving VirtualSocket still owns bytes accepted into its readable
|
|
911
|
+
// buffer. A replacement object owns only its durable acknowledgement.
|
|
912
|
+
targetToClientAck: stream.recoveredObject ? stream.receiveAck : stream.receiveSequence,
|
|
913
|
+
localEnded: stream.localEnded
|
|
914
|
+
}))
|
|
915
|
+
this.#resumeResults = []
|
|
916
|
+
this.#resumeNextPage = 0
|
|
917
|
+
const pageCount = Math.max(1, Math.ceil(streams.length / MANIFEST_PAGE_STREAMS))
|
|
918
|
+
for (let page = 0; page < pageCount; page += 1) {
|
|
919
|
+
this.#queueControlFrame(V3FrameType.RESUME, 0, this.#generation, {
|
|
920
|
+
page,
|
|
921
|
+
final: page === pageCount - 1,
|
|
922
|
+
streams: streams.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
|
|
923
|
+
})
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** @param {unknown} item - Resume item. @returns {void} */
|
|
928
|
+
#applyResumeResult(item) {
|
|
929
|
+
if (typeof item !== "object" || item === null) return
|
|
930
|
+
const result = /** @type {{streamId?: unknown, status?: unknown, opened?: unknown, clientToTargetAck?: unknown, targetToClientAck?: unknown, code?: unknown}} */ (item)
|
|
931
|
+
if (typeof result.streamId !== "number") return
|
|
932
|
+
const stream = this.#streams.get(result.streamId)
|
|
933
|
+
if (stream === undefined) return
|
|
934
|
+
if (result.status !== "resumed") {
|
|
935
|
+
this.#resumeFailures += 1
|
|
936
|
+
stream.remoteClosed = true
|
|
937
|
+
stream.socket._receiveError(new SocketductSessionLostError("Virtual stream could not be resumed", typeof result.code === "string" ? result.code : "RESUME_REJECTED"))
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
if (typeof result.clientToTargetAck === "number") this.#applyAck(stream, result.clientToTargetAck)
|
|
941
|
+
if (typeof result.targetToClientAck === "number" && result.targetToClientAck > stream.receiveSequence) throw new Error("Relay resume acknowledgement exceeds received sequence")
|
|
942
|
+
if (typeof result.targetToClientAck === "number") {
|
|
943
|
+
stream.receiveAck = Math.max(stream.receiveAck, result.targetToClientAck)
|
|
944
|
+
stream.receiveUnackedBytes = Math.max(0, stream.receiveSequence - stream.receiveAck)
|
|
945
|
+
if (stream.pendingReceiveAck !== undefined && stream.pendingReceiveAck <= stream.receiveAck) stream.pendingReceiveAck = undefined
|
|
946
|
+
}
|
|
947
|
+
// Window credit belongs to a physical generation. Recompute it from the
|
|
948
|
+
// acknowledged durable replay set instead of retaining spent old credit.
|
|
949
|
+
stream.sendWindow = this.#initialWindow()
|
|
950
|
+
if (result.opened === true) stream.opened = true
|
|
951
|
+
stream.openingSent = true
|
|
952
|
+
stream.recoveredObject = false
|
|
953
|
+
void this.#persistStreamState(stream).catch((error) => stream.socket._receiveError(asError(error)))
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** @param {ClientStream} stream - Stream. @returns {void} */
|
|
957
|
+
#openStream(stream) {
|
|
958
|
+
if (stream.socket.destroyed || stream.opened || stream.openingSent) return
|
|
959
|
+
stream.openingSent = true
|
|
960
|
+
const open = this.#openQueue.then(() => this.#persistStreamState(stream)).then(() => {
|
|
961
|
+
if (this.#streams.get(stream.id) !== stream || stream.socket.destroyed) return
|
|
962
|
+
this.#queueControlFrame(V3FrameType.OPEN, stream.id, 0, {
|
|
963
|
+
identifier: stream.identifier,
|
|
964
|
+
name: stream.name,
|
|
965
|
+
target: stream.target,
|
|
966
|
+
initialWindow: this.#initialWindow()
|
|
967
|
+
})
|
|
968
|
+
})
|
|
969
|
+
this.#openQueue = open.catch(() => {})
|
|
970
|
+
void open.catch((error) => stream.socket._receiveError(asError(error)))
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* @param {ClientStream} stream - Stream. @param {ReplayChunk} chunk - Raw bytes. @returns {void}
|
|
975
|
+
* @param chunk
|
|
976
|
+
*/
|
|
977
|
+
#queueData(stream, chunk) {
|
|
978
|
+
const wire = encodeV3Frame({type: V3FrameType.DATA, streamId: stream.id, sequence: chunk.sequence}, chunk.data)
|
|
979
|
+
stream.queue.push({wire, type: V3FrameType.DATA, payloadBytes: chunk.data.length})
|
|
980
|
+
this.#schedulePump()
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* @param {ClientStream} stream - Stream. @param {number} type - Frame type. @param {number} sequence - Position. @returns {void}
|
|
985
|
+
* @param type
|
|
986
|
+
* @param sequence
|
|
987
|
+
*/
|
|
988
|
+
#queueStreamControl(stream, type, sequence) {
|
|
989
|
+
stream.queue.push({wire: encodeV3Frame({type, streamId: stream.id, sequence}), type, payloadBytes: 0})
|
|
990
|
+
this.#schedulePump()
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Position. @param {Record<string, unknown>} value - Control body. @returns {void}
|
|
995
|
+
* @param streamId
|
|
996
|
+
* @param sequence
|
|
997
|
+
* @param value
|
|
998
|
+
*/
|
|
999
|
+
#queueControlFrame(type, streamId, sequence, value) {
|
|
1000
|
+
if (this.#closing) return
|
|
1001
|
+
const wire = encodeV3Frame({type, streamId, sequence}, encodeV3Control(value))
|
|
1002
|
+
if (this.#controlQueuedBytes + wire.length > MAX_CONTROL_QUEUE_BYTES) {
|
|
1003
|
+
this.#failAll(new SocketductSessionLostError("Socketduct control queue limit exceeded", "CONTROL_QUEUE_LIMIT"))
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
try {
|
|
1007
|
+
this.#options.aggregateBudget?.reserveControl(wire.length)
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
this.#failAll(asError(error))
|
|
1010
|
+
return
|
|
1011
|
+
}
|
|
1012
|
+
this.#controlQueue.push(wire)
|
|
1013
|
+
this.#controlQueuedBytes += wire.length
|
|
1014
|
+
this.#schedulePump()
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** @returns {void} */
|
|
1018
|
+
#schedulePump() {
|
|
1019
|
+
if (this.#pumping || !this.#authenticated || this.#physical === undefined || this.#physical.destroyed) return
|
|
1020
|
+
this.#pumping = true
|
|
1021
|
+
setImmediate(() => void this.#pump())
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** @returns {Promise<void>} */
|
|
1025
|
+
async #pump() {
|
|
1026
|
+
const ownerPhysical = this.#physical
|
|
1027
|
+
try {
|
|
1028
|
+
while (this.#authenticated && ownerPhysical !== undefined && this.#physical === ownerPhysical && !ownerPhysical.destroyed) {
|
|
1029
|
+
const physical = ownerPhysical
|
|
1030
|
+
const control = this.#controlQueue.shift()
|
|
1031
|
+
if (control !== undefined) {
|
|
1032
|
+
this.#controlQueuedBytes = Math.max(0, this.#controlQueuedBytes - control.length)
|
|
1033
|
+
this.#options.aggregateBudget?.releaseControl(control.length)
|
|
1034
|
+
if (!await writePhysical(physical, control)) break
|
|
1035
|
+
continue
|
|
1036
|
+
}
|
|
1037
|
+
if (this.#resuming) break
|
|
1038
|
+
const streams = [...this.#streams.values()]
|
|
1039
|
+
if (streams.length === 0) break
|
|
1040
|
+
let selected
|
|
1041
|
+
for (let index = 0; index < streams.length; index += 1) {
|
|
1042
|
+
const cursor = (this.#roundRobinCursor + index) % streams.length
|
|
1043
|
+
const candidate = /** @type {ClientStream} */ (streams[cursor])
|
|
1044
|
+
const frame = candidate.queue[0]
|
|
1045
|
+
if (frame !== undefined && candidate.opened && (frame.type !== V3FrameType.DATA || frame.payloadBytes <= candidate.sendWindow)) {
|
|
1046
|
+
selected = {stream: candidate, frame, next: (cursor + 1) % streams.length}
|
|
1047
|
+
break
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (selected === undefined) break
|
|
1051
|
+
selected.stream.queue.shift()
|
|
1052
|
+
this.#roundRobinCursor = selected.next
|
|
1053
|
+
if (selected.frame.type === V3FrameType.DATA) selected.stream.sendWindow -= selected.frame.payloadBytes
|
|
1054
|
+
if (!await writePhysical(physical, selected.frame.wire)) break
|
|
1055
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
1056
|
+
}
|
|
1057
|
+
} finally {
|
|
1058
|
+
this.#pumping = false
|
|
1059
|
+
if (this.#controlQueue.length > 0 || (!this.#resuming && [...this.#streams.values()].some((stream) => {
|
|
1060
|
+
const frame = stream.queue[0]
|
|
1061
|
+
return stream.opened && frame !== undefined && (frame.type !== V3FrameType.DATA || frame.payloadBytes <= stream.sendWindow)
|
|
1062
|
+
}))) this.#schedulePump()
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* @param {ClientStream} stream - Stream. @param {number} sequence - Ack. @returns {void}
|
|
1068
|
+
* @param sequence
|
|
1069
|
+
*/
|
|
1070
|
+
#applyAck(stream, sequence) {
|
|
1071
|
+
if (!Number.isSafeInteger(sequence) || sequence < stream.sendAck || sequence > stream.sendSequence) throw new Error("Invalid V3 stream acknowledgement")
|
|
1072
|
+
if (sequence === stream.sendAck) return
|
|
1073
|
+
const oldAck = stream.sendAck
|
|
1074
|
+
stream.sendAck = sequence
|
|
1075
|
+
let removed = 0
|
|
1076
|
+
stream.replay = stream.replay.filter((chunk) => {
|
|
1077
|
+
if (chunk.sequence <= sequence) {
|
|
1078
|
+
removed += chunk.data.length
|
|
1079
|
+
return false
|
|
1080
|
+
}
|
|
1081
|
+
return true
|
|
1082
|
+
})
|
|
1083
|
+
stream.bufferedBytes = Math.max(0, stream.bufferedBytes - removed)
|
|
1084
|
+
this.#aggregateBufferedBytes = Math.max(0, this.#aggregateBufferedBytes - removed)
|
|
1085
|
+
this.#options.aggregateBudget?.releaseMemory(removed)
|
|
1086
|
+
stream.sendWindow = Math.min(this.#initialWindow(), stream.sendWindow + (sequence - oldAck))
|
|
1087
|
+
void this.#queueSpoolOperation(stream, async () => {
|
|
1088
|
+
await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
|
|
1089
|
+
await this.#spool.truncateAcked(stream.identifier, sequence)
|
|
1090
|
+
if (removed > stream.spoolReservedBytes) throw new Error("Socketduct stream spool budget release underflow")
|
|
1091
|
+
stream.spoolReservedBytes -= removed
|
|
1092
|
+
this.#aggregateSpoolBytes -= removed
|
|
1093
|
+
this.#options.aggregateBudget?.releaseSpool(removed)
|
|
1094
|
+
}).catch((error) => stream.socket._receiveError(asError(error)))
|
|
1095
|
+
this.#schedulePump()
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* @param {ClientStream} stream - Stream. @param {number} sequence - Ack. @returns {void}
|
|
1100
|
+
* @param sequence
|
|
1101
|
+
*/
|
|
1102
|
+
#ackReceived(stream, sequence) {
|
|
1103
|
+
const delta = sequence - stream.receiveAck
|
|
1104
|
+
if (delta <= 0) return
|
|
1105
|
+
stream.receiveAck = sequence
|
|
1106
|
+
stream.receiveUnackedBytes = Math.max(0, stream.receiveUnackedBytes - delta)
|
|
1107
|
+
const generation = this.#generation
|
|
1108
|
+
void this.#persistStreamState(stream).then(() => {
|
|
1109
|
+
if (this.#streams.get(stream.id) === stream && !stream.remoteClosed && this.#generation === generation && this.#authenticated && !this.#resuming) this.#queueControlFrame(V3FrameType.ACK, stream.id, sequence, {})
|
|
1110
|
+
}, (error) => stream.socket._receiveError(asError(error)))
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** @returns {void} */
|
|
1114
|
+
#completeConnection() {
|
|
1115
|
+
this.#resuming = false
|
|
1116
|
+
this.#reconnectAttempts = 0
|
|
1117
|
+
this.#startHeartbeat()
|
|
1118
|
+
this.#setPhysicalState("ready")
|
|
1119
|
+
logDiagnostic(this.#options.logger, "transport.connected", {
|
|
1120
|
+
generation: this.#generation,
|
|
1121
|
+
attempt: this.#physicalAttempts,
|
|
1122
|
+
physicalConnects: this.#physicalConnects,
|
|
1123
|
+
reconnect: this.#physicalConnects > 1
|
|
1124
|
+
})
|
|
1125
|
+
if (this.#physicalConnects > 1) {
|
|
1126
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
|
|
1127
|
+
generation: this.#generation,
|
|
1128
|
+
attempt: this.#reconnectAttemptTotal,
|
|
1129
|
+
physicalConnects: this.#physicalConnects
|
|
1130
|
+
})
|
|
1131
|
+
}
|
|
1132
|
+
this.#resolveConnect?.()
|
|
1133
|
+
this.#settleConnect()
|
|
1134
|
+
for (const stream of this.#streams.values()) {
|
|
1135
|
+
if (!stream.opened) this.#openStream(stream)
|
|
1136
|
+
else stream.socket._markConnected()
|
|
1137
|
+
}
|
|
1138
|
+
this.#schedulePump()
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** @returns {void} */
|
|
1142
|
+
#settleConnect() {
|
|
1143
|
+
this.#connectPromise = undefined
|
|
1144
|
+
this.#resolveConnect = undefined
|
|
1145
|
+
this.#rejectConnect = undefined
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* @param {Error} error - Failure. @param {boolean} retry - Whether retryable. @returns {void}
|
|
1150
|
+
* @param retry
|
|
1151
|
+
*/
|
|
1152
|
+
#failAttempt(error, retry) {
|
|
1153
|
+
if (!retry || (this.#options.continuousReconnect !== true && this.#reconnectAttempts >= (this.#options.maxReconnectAttempts ?? 8))) {
|
|
1154
|
+
this.#failAll(error)
|
|
1155
|
+
return
|
|
1156
|
+
}
|
|
1157
|
+
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
1158
|
+
generation: this.#generation,
|
|
1159
|
+
attempt: this.#physicalAttempts,
|
|
1160
|
+
...errorDiagnostics(error)
|
|
1161
|
+
})
|
|
1162
|
+
this.#scheduleReconnect()
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** @returns {void} */
|
|
1166
|
+
#scheduleReconnect() {
|
|
1167
|
+
if (this.#closing || this.#reconnectTimer !== undefined) return
|
|
1168
|
+
if (this.#options.continuousReconnect !== true && this.#reconnectAttempts >= (this.#options.maxReconnectAttempts ?? 8)) {
|
|
1169
|
+
this.#failAll(new SocketductSessionLostError("Socketduct reconnect limit exceeded", "RECONNECT_EXHAUSTED"))
|
|
1170
|
+
return
|
|
1171
|
+
}
|
|
1172
|
+
this.#setPhysicalState("reconnecting")
|
|
1173
|
+
const delayMs = this.#options.reconnectDelayMs ?? 25
|
|
1174
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_scheduled", {
|
|
1175
|
+
generation: this.#generation,
|
|
1176
|
+
attempt: this.#reconnectAttemptTotal + 1,
|
|
1177
|
+
delayMs
|
|
1178
|
+
})
|
|
1179
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
1180
|
+
this.#reconnectTimer = undefined
|
|
1181
|
+
if (this.#connectPromise === undefined) {
|
|
1182
|
+
this.#connectPromise = new Promise((resolve, reject) => {
|
|
1183
|
+
this.#resolveConnect = resolve
|
|
1184
|
+
this.#rejectConnect = reject
|
|
1185
|
+
})
|
|
1186
|
+
this.#connectPromise.catch(() => {})
|
|
1187
|
+
}
|
|
1188
|
+
this.#connectPhysical()
|
|
1189
|
+
}, delayMs)
|
|
1190
|
+
this.#reconnectTimer.unref?.()
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** @param {Error} error - Terminal connection failure. @returns {void} */
|
|
1194
|
+
#failAll(error) {
|
|
1195
|
+
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
1196
|
+
generation: this.#generation,
|
|
1197
|
+
attempt: this.#physicalAttempts,
|
|
1198
|
+
...errorDiagnostics(error)
|
|
1199
|
+
})
|
|
1200
|
+
this.#closing = true
|
|
1201
|
+
this.#setPhysicalState("closed", error)
|
|
1202
|
+
this.#stopHeartbeat()
|
|
1203
|
+
this.#clearControlQueue()
|
|
1204
|
+
this.#physical?.destroy()
|
|
1205
|
+
this.#rejectConnect?.(error)
|
|
1206
|
+
this.#settleConnect()
|
|
1207
|
+
for (const stream of [...this.#streams.values()]) stream.socket._receiveError(error)
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** @returns {number} */
|
|
1211
|
+
#initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
|
|
1212
|
+
|
|
1213
|
+
/** @returns {number} */
|
|
1214
|
+
#maxFrameBytes() { return Math.min(this.#options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, 64 * 1024) }
|
|
1215
|
+
|
|
1216
|
+
/** @returns {number} */
|
|
1217
|
+
#maxStreamBytes() { return this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BYTES }
|
|
1218
|
+
|
|
1219
|
+
/** @returns {number} */
|
|
1220
|
+
#maxConnectionBytes() { return this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BYTES }
|
|
1221
|
+
|
|
1222
|
+
/** @returns {void} */
|
|
1223
|
+
#startHeartbeat() {
|
|
1224
|
+
this.#stopHeartbeat()
|
|
1225
|
+
const interval = this.#options.heartbeatIntervalMs ?? 30_000
|
|
1226
|
+
const timeout = this.#options.heartbeatTimeoutMs ?? 90_000
|
|
1227
|
+
this.#lastPongAt = Date.now()
|
|
1228
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
1229
|
+
if (!this.#authenticated || this.#physical === undefined) return
|
|
1230
|
+
if (Date.now() - this.#lastPongAt > timeout) {
|
|
1231
|
+
this.#physical.destroy(new Error("Socketduct heartbeat timeout"))
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
1234
|
+
this.#pingSequence += 1
|
|
1235
|
+
this.#queueControlFrame(V3FrameType.PING, 0, this.#pingSequence, {})
|
|
1236
|
+
}, interval)
|
|
1237
|
+
this.#heartbeatTimer.unref?.()
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** @returns {void} */
|
|
1241
|
+
#stopHeartbeat() {
|
|
1242
|
+
if (this.#heartbeatTimer !== undefined) clearInterval(this.#heartbeatTimer)
|
|
1243
|
+
this.#heartbeatTimer = undefined
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** @returns {void} */
|
|
1247
|
+
#clearControlQueue() {
|
|
1248
|
+
if (this.#controlQueuedBytes > 0) this.#options.aggregateBudget?.releaseControl(this.#controlQueuedBytes)
|
|
1249
|
+
this.#controlQueue = []
|
|
1250
|
+
this.#controlQueuedBytes = 0
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* @param {"configured" | "connecting" | "ready" | "reconnecting" | "disconnected" | "closed"} state - New lifecycle state.
|
|
1255
|
+
* @param {Error} [error] - Terminal error.
|
|
1256
|
+
* @returns {void}
|
|
1257
|
+
*/
|
|
1258
|
+
#setPhysicalState(state, error) {
|
|
1259
|
+
if (this.#physicalState === state) return
|
|
1260
|
+
this.#physicalState = state
|
|
1261
|
+
this.emit("stateChange", state, error)
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** @param {import('node:net').Socket} physical - Attempt owner. @returns {void} */
|
|
1265
|
+
#clearAttemptTimeout(physical) {
|
|
1266
|
+
const timer = this.#attemptTimers.get(physical)
|
|
1267
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
1268
|
+
this.#attemptTimers.delete(physical)
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** @param {ClientStream} stream - Stream. @returns {ClientRecoveryState} */
|
|
1272
|
+
#recoveryState(stream) {
|
|
1273
|
+
return {
|
|
1274
|
+
version: 3,
|
|
1275
|
+
connectionId: this.#connectionId,
|
|
1276
|
+
generation: this.#generation,
|
|
1277
|
+
streamId: stream.id,
|
|
1278
|
+
identifier: stream.identifier,
|
|
1279
|
+
name: stream.name,
|
|
1280
|
+
target: stream.target,
|
|
1281
|
+
opened: stream.opened,
|
|
1282
|
+
openingSent: stream.openingSent,
|
|
1283
|
+
localEnded: stream.localEnded,
|
|
1284
|
+
sendSequence: stream.sendSequence,
|
|
1285
|
+
sendAck: stream.sendAck,
|
|
1286
|
+
receiveSequence: stream.receiveSequence,
|
|
1287
|
+
receiveAck: stream.receiveAck
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** @param {ClientStream} stream - Stream. @returns {Promise<void>} */
|
|
1292
|
+
async #persistStreamState(stream) {
|
|
1293
|
+
await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* @param {ClientStream} stream - Stream spool owner. @param {() => Promise<void>} operation - Serialized operation. @returns {Promise<void>}
|
|
1298
|
+
* @param operation
|
|
1299
|
+
*/
|
|
1300
|
+
async #queueSpoolOperation(stream, operation) {
|
|
1301
|
+
const next = stream.spoolQueue.then(async () => {
|
|
1302
|
+
await stream.spoolReady
|
|
1303
|
+
await operation()
|
|
1304
|
+
})
|
|
1305
|
+
stream.spoolQueue = next.catch(() => {})
|
|
1306
|
+
await next
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** @param {ClientStream} stream - Stream spool owner. @returns {Promise<void>} */
|
|
1310
|
+
async #removeSpool(stream) {
|
|
1311
|
+
if (stream.spoolRemovalPromise === undefined) {
|
|
1312
|
+
stream.spoolRemovalQueued = true
|
|
1313
|
+
stream.spoolRemovalPromise = this.#queueSpoolOperation(stream, async () => {
|
|
1314
|
+
await this.#spool.remove(stream.identifier)
|
|
1315
|
+
const removed = stream.spoolReservedBytes
|
|
1316
|
+
stream.spoolReservedBytes = 0
|
|
1317
|
+
this.#aggregateSpoolBytes -= removed
|
|
1318
|
+
this.#options.aggregateBudget?.releaseSpool(removed)
|
|
1319
|
+
})
|
|
1320
|
+
}
|
|
1321
|
+
await stream.spoolRemovalPromise
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/** @param {ClientStream} stream - Destroyed spool owner. @returns {void} */
|
|
1325
|
+
#trackSpoolCleanup(stream) {
|
|
1326
|
+
const removal = stream.ownsSpool
|
|
1327
|
+
? this.#removeSpool(stream)
|
|
1328
|
+
: stream.spoolReady.then(() => stream.ownsSpool ? this.#removeSpool(stream) : undefined)
|
|
1329
|
+
const task = removal.catch((cleanupError) => {
|
|
1330
|
+
const error = asError(cleanupError)
|
|
1331
|
+
this.#handleSpoolCleanupFailure(stream, error)
|
|
1332
|
+
throw error
|
|
1333
|
+
})
|
|
1334
|
+
this.#spoolCleanupTasks.add(task)
|
|
1335
|
+
void task.catch(() => {}).finally(() => this.#spoolCleanupTasks.delete(task))
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* @param {ClientStream} stream - Failed spool owner.
|
|
1340
|
+
* @param {Error} error - Durable cleanup failure.
|
|
1341
|
+
* @returns {void}
|
|
1342
|
+
*/
|
|
1343
|
+
#handleSpoolCleanupFailure(stream, error) {
|
|
1344
|
+
if (stream.spoolCleanupFailureRecorded) return
|
|
1345
|
+
stream.spoolCleanupFailureRecorded = true
|
|
1346
|
+
this.#spoolCleanupFailures += 1
|
|
1347
|
+
if (!this.#closing) {
|
|
1348
|
+
const lifecycleError = new SocketductSessionLostError("Socketduct durable spool cleanup failed", "SPOOL_CLEANUP_FAILED")
|
|
1349
|
+
lifecycleError.cause = error
|
|
1350
|
+
this.#failAll(lifecycleError)
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Constructs a reusable protocol-v3 physical connection. No network resource is
|
|
1357
|
+
* allocated until its first virtual stream is opened.
|
|
1358
|
+
* @param {MultiplexedConnectionOptions} options - Connection options.
|
|
1359
|
+
* @returns {MultiplexedSocketductConnection} Shared transport owner.
|
|
1360
|
+
*/
|
|
1361
|
+
export function createMultiplexedSocketductConnection(options) {
|
|
1362
|
+
return new MultiplexedSocketductConnection(options)
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/** @param {MultiplexedConnectionOptions} options - Options. @returns {void} */
|
|
1366
|
+
function validateConnectionOptions(options) {
|
|
1367
|
+
if (typeof options !== "object" || options === null || typeof options.relay?.host !== "string" || !validPort(options.relay?.port) || typeof options.relay?.token !== "string" || options.relay.token.length === 0) {
|
|
1368
|
+
throw new TypeError("Multiplexed Socketduct relay host, port, and token are required")
|
|
1369
|
+
}
|
|
1370
|
+
if (options.connectionId !== undefined && !STABLE_IDENTIFIER.test(options.connectionId)) throw new TypeError("connectionId must be 8-128 URL-safe opaque characters")
|
|
1371
|
+
if (options.continuousReconnect !== undefined && typeof options.continuousReconnect !== "boolean") throw new TypeError("continuousReconnect must be a boolean")
|
|
1372
|
+
for (const [name, value] of Object.entries({
|
|
1373
|
+
initialWindowBytes: options.initialWindowBytes,
|
|
1374
|
+
maxFrameBytes: options.maxFrameBytes,
|
|
1375
|
+
maxStreamBufferBytes: options.maxStreamBufferBytes,
|
|
1376
|
+
maxConnectionBufferBytes: options.maxConnectionBufferBytes,
|
|
1377
|
+
maxStreams: options.maxStreams,
|
|
1378
|
+
maxReconnectAttempts: options.maxReconnectAttempts,
|
|
1379
|
+
connectTimeoutMs: options.connectTimeoutMs,
|
|
1380
|
+
maxAgeMs: options.maxAgeMs,
|
|
1381
|
+
heartbeatIntervalMs: options.heartbeatIntervalMs,
|
|
1382
|
+
heartbeatTimeoutMs: options.heartbeatTimeoutMs
|
|
1383
|
+
})) {
|
|
1384
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new RangeError(`${name} must be a positive integer`)
|
|
1385
|
+
}
|
|
1386
|
+
if ((options.tls?.certFile === undefined) !== (options.tls?.keyFile === undefined)) throw new Error("TLS certFile and keyFile must both be set")
|
|
1387
|
+
if ((options.heartbeatTimeoutMs ?? 90_000) <= (options.heartbeatIntervalMs ?? 30_000)) throw new RangeError("heartbeatTimeoutMs must exceed heartbeatIntervalMs")
|
|
1388
|
+
if ((options.maxFrameBytes ?? DEFAULT_FRAME_BYTES) > (options.initialWindowBytes ?? DEFAULT_WINDOW)) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
|
|
1389
|
+
if ((options.initialWindowBytes ?? DEFAULT_WINDOW) > (options.maxStreamBufferBytes ?? DEFAULT_STREAM_BYTES)) throw new RangeError("initialWindowBytes must not exceed maxStreamBufferBytes")
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* @param {Record<string, unknown>} value - Parsed durable state.
|
|
1394
|
+
* @param {{connectionId: string, identifier: string, target: {host: string, port: number}, sessionName?: string}} expected - Caller-owned identity.
|
|
1395
|
+
* @returns {ClientRecoveryState} Validated recovery state.
|
|
1396
|
+
*/
|
|
1397
|
+
function validateRecoveryState(value, expected) {
|
|
1398
|
+
const state = /** @type {Partial<ClientRecoveryState>} */ (value)
|
|
1399
|
+
const sequences = [state.generation, state.streamId, state.sendSequence, state.sendAck, state.receiveSequence, state.receiveAck]
|
|
1400
|
+
const validSequences = sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0)
|
|
1401
|
+
const validTarget = state.target?.host === expected.target.host && state.target?.port === expected.target.port
|
|
1402
|
+
if (state.version !== 3 || state.connectionId !== expected.connectionId || state.identifier !== expected.identifier || !validSequences || state.generation === 0 || state.streamId === 0 || /** @type {number} */ (state.streamId) % 2 === 0 || /** @type {number} */ (state.streamId) > 0xffff_fffd || typeof state.name !== "string" || state.name.length === 0 || state.name.length > 256 || !validTarget || typeof state.opened !== "boolean" || state.openingSent !== true || typeof state.localEnded !== "boolean" || /** @type {number} */ (state.sendAck) > /** @type {number} */ (state.sendSequence) || /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence) || (expected.sessionName !== undefined && state.name !== expected.sessionName)) {
|
|
1403
|
+
throw new Error("Invalid Socketduct recovery identity or sequence state")
|
|
1404
|
+
}
|
|
1405
|
+
return /** @type {ClientRecoveryState} */ (state)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* @param {ClientStream} stream - Recovered owner.
|
|
1410
|
+
* @param {import('../spool/spool-file.js').SpoolChunk[]} chunks - Durable chunks after ACK.
|
|
1411
|
+
* @param {number} maxBytes - Per-stream bound.
|
|
1412
|
+
* @returns {void}
|
|
1413
|
+
*/
|
|
1414
|
+
function validateRecoveredChunks(stream, chunks, maxBytes) {
|
|
1415
|
+
let sequence = stream.sendAck
|
|
1416
|
+
let bytes = 0
|
|
1417
|
+
for (const chunk of chunks) {
|
|
1418
|
+
bytes += chunk.data.length
|
|
1419
|
+
if (chunk.data.length === 0 || chunk.sequence !== sequence + chunk.data.length || chunk.sequence > stream.sendSequence) throw new Error("Invalid Socketduct recovery chunk sequence")
|
|
1420
|
+
sequence = chunk.sequence
|
|
1421
|
+
}
|
|
1422
|
+
if (sequence !== stream.sendSequence || bytes > maxBytes) throw new Error("Invalid or oversized Socketduct recovery replay state")
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** @param {VirtualConnectionOptions} options - Options. @returns {{host: string, port: number}} */
|
|
1426
|
+
function normalizeTarget(options) {
|
|
1427
|
+
const target = options.target ?? {host: options.host, port: options.port}
|
|
1428
|
+
if (typeof target.host !== "string" || target.host.length === 0 || target.host.length > 253 || !validPort(target.port)) throw new TypeError("Virtual connection target host and port are required")
|
|
1429
|
+
return {host: target.host, port: target.port}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** @param {unknown} port - Port. @returns {port is number} */
|
|
1433
|
+
function validPort(port) { return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535 }
|
|
1434
|
+
|
|
1435
|
+
/** @param {{caFile?: string, certFile?: string, keyFile?: string, servername?: string, rejectUnauthorized?: boolean}} options - TLS files. @returns {Record<string, unknown>} */
|
|
1436
|
+
function resolveTls(options) {
|
|
1437
|
+
return {
|
|
1438
|
+
rejectUnauthorized: options.rejectUnauthorized !== false,
|
|
1439
|
+
...(options.caFile === undefined ? {} : {ca: readFileSync(options.caFile)}),
|
|
1440
|
+
...(options.certFile === undefined ? {} : {cert: readFileSync(options.certFile)}),
|
|
1441
|
+
...(options.keyFile === undefined ? {} : {key: readFileSync(options.keyFile)}),
|
|
1442
|
+
...(options.servername === undefined ? {} : {servername: options.servername})
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** @param {{host: string, port: number, tls?: Record<string, unknown>}} options - Transport. @returns {import('node:net').Socket} */
|
|
1447
|
+
function createPhysicalSocket(options) {
|
|
1448
|
+
return options.tls === undefined ? netConnect({host: options.host, port: options.port}) : tlsConnect({host: options.host, port: options.port, ...options.tls})
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* @param {import('node:net').Socket} socket - Physical socket. @param {Buffer} bytes - Frame. @returns {Promise<boolean>}
|
|
1453
|
+
* @param bytes
|
|
1454
|
+
*/
|
|
1455
|
+
async function writePhysical(socket, bytes) {
|
|
1456
|
+
if (socket.destroyed || !socket.writable) return false
|
|
1457
|
+
if (socket.write(bytes)) return true
|
|
1458
|
+
return await new Promise((resolve) => {
|
|
1459
|
+
const cleanup = () => {
|
|
1460
|
+
socket.off("drain", onDrain)
|
|
1461
|
+
socket.off("close", onClose)
|
|
1462
|
+
}
|
|
1463
|
+
const onDrain = () => { cleanup(); resolve(true) }
|
|
1464
|
+
const onClose = () => { cleanup(); resolve(false) }
|
|
1465
|
+
socket.once("drain", onDrain)
|
|
1466
|
+
socket.once("close", onClose)
|
|
1467
|
+
})
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. @returns {void}
|
|
1472
|
+
* @param callback
|
|
1473
|
+
*/
|
|
1474
|
+
function installConnectCallback(socket, callback) {
|
|
1475
|
+
let settled = false
|
|
1476
|
+
const cleanup = () => {
|
|
1477
|
+
socket.off("connect", onConnect)
|
|
1478
|
+
socket.off("error", onError)
|
|
1479
|
+
}
|
|
1480
|
+
const onConnect = () => {
|
|
1481
|
+
if (settled) return
|
|
1482
|
+
settled = true
|
|
1483
|
+
cleanup()
|
|
1484
|
+
callback(null, socket)
|
|
1485
|
+
}
|
|
1486
|
+
/** @param {Error} error - Pre-connect error. */
|
|
1487
|
+
const onError = (error) => {
|
|
1488
|
+
if (settled) return
|
|
1489
|
+
settled = true
|
|
1490
|
+
cleanup()
|
|
1491
|
+
callback(error, socket)
|
|
1492
|
+
}
|
|
1493
|
+
socket.once("connect", onConnect)
|
|
1494
|
+
socket.once("error", onError)
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/** @param {unknown} value - Thrown value. @returns {Error} */
|
|
1498
|
+
function asError(value) { return value instanceof Error ? value : new Error(String(value)) }
|
|
1499
|
+
|
|
1500
|
+
/** @param {Error} error - Transport error. @returns {boolean} */
|
|
1501
|
+
function isFatalTransportError(error) {
|
|
1502
|
+
const code = /** @type {{code?: unknown}} */ (error).code
|
|
1503
|
+
return typeof code === "string" && (code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_") || code.includes("CERT"))
|
|
1504
|
+
}
|