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,721 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-jsdoc, jsdoc/require-param, jsdoc/require-property-description, jsdoc/require-returns */
|
|
2
|
+
import {randomBytes} from "node:crypto"
|
|
3
|
+
import {EventEmitter} from "node:events"
|
|
4
|
+
|
|
5
|
+
import {availableCodecs, getMaxDecompressedBytes, ZSTD_CODEC, zstdAvailable} from "../protocol/compression.js"
|
|
6
|
+
import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
|
|
7
|
+
import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
|
|
8
|
+
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
|
|
9
|
+
import {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "../client/virtual-socket.js"
|
|
10
|
+
import {
|
|
11
|
+
asError,
|
|
12
|
+
createHubSocket,
|
|
13
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
14
|
+
DEFAULT_FRAME_BYTES,
|
|
15
|
+
DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
|
|
16
|
+
DEFAULT_MAX_STREAMS,
|
|
17
|
+
DEFAULT_WINDOW,
|
|
18
|
+
GATEWAY_ID,
|
|
19
|
+
MAX_CONTROL_BYTES,
|
|
20
|
+
parseTarget,
|
|
21
|
+
positiveInteger,
|
|
22
|
+
resolveVerifiedTls,
|
|
23
|
+
STABLE_IDENTIFIER,
|
|
24
|
+
writeSocket
|
|
25
|
+
} from "./common.js"
|
|
26
|
+
|
|
27
|
+
const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
|
|
28
|
+
const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} ReverseGatewayConnectionOptions
|
|
32
|
+
* @property {string} gatewayId - Registered home gateway selected by this member.
|
|
33
|
+
* @property {string} [connectionId] - Stable client transport identity.
|
|
34
|
+
* @property {{host: string, port: number, token: string}} hub - Server3 hub endpoint.
|
|
35
|
+
* @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string}} tls - Mandatory verified outer TLS.
|
|
36
|
+
* @property {number} [initialWindowBytes] - Flow-control window.
|
|
37
|
+
* @property {number} [maxFrameBytes] - Maximum raw DATA frame.
|
|
38
|
+
* @property {number} [maxStreams] - Concurrent stream limit.
|
|
39
|
+
* @property {number} [maxStreamBufferBytes] - Per-stream client output bound.
|
|
40
|
+
* @property {number} [maxConnectionBufferBytes] - Aggregate client output bound.
|
|
41
|
+
* @property {number} [connectTimeoutMs] - TLS/auth deadline.
|
|
42
|
+
* @property {number} [reconnectDelayMs] - Initial reconnect delay.
|
|
43
|
+
* @property {number} [maxReconnectDelayMs] - Maximum reconnect delay.
|
|
44
|
+
* @property {number} [initialGeneration] - Monotonic seed for controlled replacement; defaults to wall-clock microseconds.
|
|
45
|
+
* @property {boolean} [continuousReconnect] - Keep the member warm after loss, default true.
|
|
46
|
+
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
|
|
47
|
+
* @property {(options: {host: string, port: number, tls: object}) => import('node:tls').TLSSocket} [createPhysicalSocket] - Test transport factory.
|
|
48
|
+
* @property {{reserveData(bytes: number): void, reserveMemory(bytes: number): void, releaseMemory(bytes: number): void, releaseSpool(bytes: number): void, reserveControl(bytes: number): void, releaseControl(bytes: number): void}} [aggregateBudget] - Optional transport-set budget.
|
|
49
|
+
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
|
|
50
|
+
* @typedef {object} ClientChunk
|
|
51
|
+
* @property {number} sequence
|
|
52
|
+
* @property {Buffer} data
|
|
53
|
+
* @property {boolean} sent
|
|
54
|
+
* @typedef {object} PendingClientWrite
|
|
55
|
+
* @property {Buffer} data
|
|
56
|
+
* @property {number} offset
|
|
57
|
+
* @property {(error?: Error | null) => void} callback
|
|
58
|
+
* @typedef {object} ReverseClientStream
|
|
59
|
+
* @property {number} id
|
|
60
|
+
* @property {string} identifier
|
|
61
|
+
* @property {string} name
|
|
62
|
+
* @property {{host: string, port: number}} target
|
|
63
|
+
* @property {VirtualSocket} socket
|
|
64
|
+
* @property {'opening' | 'open' | 'half-closed' | 'closed' | 'reset'} state
|
|
65
|
+
* @property {number} sendSequence
|
|
66
|
+
* @property {number} transmittedSequence
|
|
67
|
+
* @property {number} sendAck
|
|
68
|
+
* @property {number} sendWindow
|
|
69
|
+
* @property {number} sendWindowLimit
|
|
70
|
+
* @property {number} receiveSequence
|
|
71
|
+
* @property {number} receiveAck
|
|
72
|
+
* @property {number | undefined} pendingReceiveAck
|
|
73
|
+
* @property {boolean} remoteEnded
|
|
74
|
+
* @property {number} bufferedBytes
|
|
75
|
+
* @property {ClientChunk[]} replay
|
|
76
|
+
* @property {AdaptiveCompressor} compressor
|
|
77
|
+
* @property {boolean} pumping
|
|
78
|
+
* @property {PendingClientWrite | undefined} pendingWrite
|
|
79
|
+
* @property {boolean} progressingWrite
|
|
80
|
+
* @property {boolean} localEnded
|
|
81
|
+
* @property {boolean} endSent
|
|
82
|
+
* @property {((error?: Error | null) => void) | undefined} endCallback
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/** Server3/TensorBuzz-facing virtual socket connection to one registered gateway. */
|
|
86
|
+
export class ReverseGatewayConnection extends EventEmitter {
|
|
87
|
+
/** @type {ReverseGatewayConnectionOptions} */ #options
|
|
88
|
+
/** @type {string} */ #connectionId
|
|
89
|
+
/** @type {import('node:tls').TLSSocket | undefined} */ #physical
|
|
90
|
+
/** @type {Map<number, ReverseClientStream>} */ #streams = new Map()
|
|
91
|
+
/** @type {number} */ #nextStreamId = 1
|
|
92
|
+
/** @type {'configured' | 'connecting' | 'ready' | 'reconnecting' | 'closed'} */ #state = "configured"
|
|
93
|
+
/** @type {number} */ #generation
|
|
94
|
+
/** @type {string | undefined} */ #codec
|
|
95
|
+
/** @type {boolean} */ #closing = false
|
|
96
|
+
/** @type {boolean} */ #fatal = false
|
|
97
|
+
/** @type {Error | undefined} */ #fatalError
|
|
98
|
+
/** @type {boolean} */ #referenced = true
|
|
99
|
+
/** @type {boolean} */ #warm = false
|
|
100
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
|
|
101
|
+
/** @type {Promise<void> | undefined} */ #connectPromise
|
|
102
|
+
/** @type {(() => void) | undefined} */ #resolveConnect
|
|
103
|
+
/** @type {((error: Error) => void) | undefined} */ #rejectConnect
|
|
104
|
+
/** @type {number} */ #reconnectDelay
|
|
105
|
+
/** @type {number} */ #sendWindowLimit
|
|
106
|
+
/** @type {number} */ #physicalConnects = 0
|
|
107
|
+
/** @type {number} */ #physicalAttempts = 0
|
|
108
|
+
/** @type {number} */ #reconnectAttempts = 0
|
|
109
|
+
/** @type {number} */ #bufferedBytes = 0
|
|
110
|
+
/** @type {number} */ #rawDataBytes = 0
|
|
111
|
+
/** @type {number} */ #wireDataBytes = 0
|
|
112
|
+
/** @type {number} */ #lastProgressedStreamId = 0
|
|
113
|
+
|
|
114
|
+
/** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
|
|
115
|
+
constructor(options) {
|
|
116
|
+
super()
|
|
117
|
+
validateOptions(options)
|
|
118
|
+
this.#options = options
|
|
119
|
+
this.#connectionId = options.connectionId ?? randomBytes(24).toString("base64url")
|
|
120
|
+
this.#generation = options.initialGeneration ?? Date.now() * 1000
|
|
121
|
+
this.#reconnectDelay = options.reconnectDelayMs ?? 250
|
|
122
|
+
this.#sendWindowLimit = this.#initialWindow()
|
|
123
|
+
this.createConnection = this.createConnection.bind(this)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Warms the selected reverse member. */
|
|
127
|
+
connect() {
|
|
128
|
+
if (this.#closing) return Promise.reject(closedError())
|
|
129
|
+
if (this.#fatalError !== undefined) return Promise.reject(this.#fatalError)
|
|
130
|
+
if (this.#state === "ready") return Promise.resolve()
|
|
131
|
+
if (this.#connectPromise !== undefined) return this.#connectPromise
|
|
132
|
+
this.#warm = true
|
|
133
|
+
this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
|
|
134
|
+
this.#connectPhysical()
|
|
135
|
+
return this.#connectPromise
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Allocates one stream on the already-warm gateway transport. */
|
|
139
|
+
createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
|
|
140
|
+
if (this.#closing) throw closedError()
|
|
141
|
+
if (this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
|
|
142
|
+
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
|
|
143
|
+
const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
|
|
144
|
+
if (target === undefined) throw new TypeError("Reverse virtual connection target host and port are required")
|
|
145
|
+
const identifier = options.identifier ?? randomBytes(24).toString("base64url")
|
|
146
|
+
if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
|
|
147
|
+
const id = this.#allocateStreamId()
|
|
148
|
+
const name = options.sessionName ?? `socketduct-reverse-${id}`
|
|
149
|
+
if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
|
|
150
|
+
const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
|
|
151
|
+
...target,
|
|
152
|
+
...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
|
|
153
|
+
...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
|
|
154
|
+
...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
|
|
155
|
+
})
|
|
156
|
+
/** @type {ReverseClientStream} */
|
|
157
|
+
const stream = {
|
|
158
|
+
id, identifier, name, target, socket,
|
|
159
|
+
state: "opening",
|
|
160
|
+
sendSequence: 0,
|
|
161
|
+
transmittedSequence: 0,
|
|
162
|
+
sendAck: 0,
|
|
163
|
+
sendWindow: this.#sendWindowLimit,
|
|
164
|
+
sendWindowLimit: this.#sendWindowLimit,
|
|
165
|
+
receiveSequence: 0,
|
|
166
|
+
receiveAck: 0,
|
|
167
|
+
pendingReceiveAck: undefined,
|
|
168
|
+
remoteEnded: false,
|
|
169
|
+
bufferedBytes: 0,
|
|
170
|
+
replay: [],
|
|
171
|
+
compressor: new AdaptiveCompressor(),
|
|
172
|
+
pumping: false,
|
|
173
|
+
pendingWrite: undefined,
|
|
174
|
+
progressingWrite: false,
|
|
175
|
+
localEnded: false,
|
|
176
|
+
endSent: false,
|
|
177
|
+
endCallback: undefined
|
|
178
|
+
}
|
|
179
|
+
this.#streams.set(id, stream)
|
|
180
|
+
logDiagnostic(this.#options.logger, "virtual_stream.opening", {
|
|
181
|
+
streamId: id,
|
|
182
|
+
generation: this.#generation,
|
|
183
|
+
gatewayId: this.#options.gatewayId,
|
|
184
|
+
recovered: false
|
|
185
|
+
})
|
|
186
|
+
if (callback !== undefined) installCallback(socket, callback)
|
|
187
|
+
void this.#sendControl(V3FrameType.OPEN, id, 0, {gatewayId: this.#options.gatewayId, identifier, name, target, initialWindow: this.#initialWindow()}).catch((error) => socket._receiveError(asError(error)))
|
|
188
|
+
return socket
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Reverse established operations are intentionally not recoverable across process/transport loss. */
|
|
192
|
+
registerRecoveredConnection() {
|
|
193
|
+
throw Object.assign(new Error("Reverse gateway streams cannot be recovered; reconcile the Docker operation before retrying"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
status() {
|
|
197
|
+
/** @type {Record<string, number>} */
|
|
198
|
+
const streamStates = {}
|
|
199
|
+
for (const stream of this.#streams.values()) streamStates[stream.state] = (streamStates[stream.state] ?? 0) + 1
|
|
200
|
+
return {
|
|
201
|
+
physicalState: this.#state,
|
|
202
|
+
connectionGeneration: this.#generation,
|
|
203
|
+
virtualStreams: this.#streams.size,
|
|
204
|
+
streamStates,
|
|
205
|
+
queuedBytes: this.#bufferedBytes,
|
|
206
|
+
dataQueuedBytes: this.#bufferedBytes,
|
|
207
|
+
controlQueuedBytes: 0,
|
|
208
|
+
spoolBytes: 0,
|
|
209
|
+
spoolCleanupFailures: 0,
|
|
210
|
+
physicalConnects: this.#physicalConnects,
|
|
211
|
+
reconnectAttempts: this.#reconnectAttempts,
|
|
212
|
+
reconnectSuccesses: Math.max(0, this.#physicalConnects - 1),
|
|
213
|
+
resumeFailures: 0,
|
|
214
|
+
compression: this.#codec ?? null,
|
|
215
|
+
rawDataBytes: this.#rawDataBytes,
|
|
216
|
+
wireDataBytes: this.#wireDataBytes,
|
|
217
|
+
compressionBytesSaved: Math.max(0, this.#rawDataBytes - this.#wireDataBytes)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
physicalAddress() { return {address: this.#physical?.localAddress, port: this.#physical?.localPort} }
|
|
222
|
+
ref() { this.#referenced = true; this.#physical?.ref(); this.#reconnectTimer?.ref?.(); return this }
|
|
223
|
+
unref() { this.#referenced = false; this.#physical?.unref(); this.#reconnectTimer?.unref?.(); return this }
|
|
224
|
+
|
|
225
|
+
async close() {
|
|
226
|
+
if (this.#closing) return
|
|
227
|
+
this.#closing = true
|
|
228
|
+
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
229
|
+
this.#reconnectTimer = undefined
|
|
230
|
+
this.#setState("closed")
|
|
231
|
+
for (const stream of [...this.#streams.values()]) stream.socket.destroy()
|
|
232
|
+
const physical = this.#physical
|
|
233
|
+
this.#physical = undefined
|
|
234
|
+
if (physical !== undefined && !physical.destroyed) {
|
|
235
|
+
await writeSocket(physical, encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({reason: "client_shutdown"}))).catch(() => false)
|
|
236
|
+
physical.destroy()
|
|
237
|
+
}
|
|
238
|
+
this.#rejectConnect?.(closedError())
|
|
239
|
+
this.#settleConnect()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Detach is terminal because ambiguous reverse streams are never durably replayed. */
|
|
243
|
+
detach() { return this.close() }
|
|
244
|
+
|
|
245
|
+
_writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
|
|
246
|
+
const stream = this.#streams.get(streamId)
|
|
247
|
+
if (stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
|
|
248
|
+
stream.pendingWrite = {data, offset: 0, callback}
|
|
249
|
+
this.#progressPendingWrites()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_endStream(/** @type {number} */ streamId, /** @type {(error?: Error | null) => void} */ callback) {
|
|
253
|
+
const stream = this.#streams.get(streamId)
|
|
254
|
+
if (stream === undefined || stream.localEnded) { callback(); return }
|
|
255
|
+
stream.localEnded = true
|
|
256
|
+
if (stream.state === "closed") { callback(); return }
|
|
257
|
+
stream.endCallback = callback
|
|
258
|
+
void this.#pumpStream(stream).catch((error) => this.#finishEnd(stream, asError(error)))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_destroyStream(/** @type {number} */ streamId, /** @type {Error | null} */ error) {
|
|
262
|
+
const stream = this.#streams.get(streamId)
|
|
263
|
+
if (stream === undefined) return
|
|
264
|
+
const wasClosed = stream.state === "closed"
|
|
265
|
+
logDiagnostic(this.#options.logger, error === null ? "virtual_stream.closed" : "virtual_stream.error", {
|
|
266
|
+
streamId,
|
|
267
|
+
generation: this.#generation,
|
|
268
|
+
gatewayId: this.#options.gatewayId,
|
|
269
|
+
...(error === null ? {} : errorDiagnostics(error))
|
|
270
|
+
})
|
|
271
|
+
this.#retireStream(stream, error ?? new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED"))
|
|
272
|
+
if (!wasClosed && !this.#closing && this.#state === "ready") void this.#sendControl(V3FrameType.RESET, streamId, 0, {code: error instanceof SocketductStreamError ? error.code : "CLIENT_CANCELLED"}).catch(() => {})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_resumeReadable(/** @type {number} */ streamId) {
|
|
276
|
+
const stream = this.#streams.get(streamId)
|
|
277
|
+
if (stream?.pendingReceiveAck === undefined) return
|
|
278
|
+
const sequence = stream.pendingReceiveAck
|
|
279
|
+
stream.pendingReceiveAck = undefined
|
|
280
|
+
this.#ackReceived(stream, sequence)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#allocateStreamId() {
|
|
284
|
+
const id = this.#nextStreamId
|
|
285
|
+
if (id > 0xffff_fffd) throw new Error("Reverse v3 stream ID space exhausted")
|
|
286
|
+
this.#nextStreamId += 2
|
|
287
|
+
return id
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#connectPhysical() {
|
|
291
|
+
if (this.#closing || this.#fatal || this.#physical !== undefined) return
|
|
292
|
+
this.#sendWindowLimit = this.#initialWindow()
|
|
293
|
+
this.#setState(this.#physicalConnects === 0 ? "connecting" : "reconnecting")
|
|
294
|
+
this.#generation += 1
|
|
295
|
+
this.#physicalAttempts += 1
|
|
296
|
+
const generation = this.#generation
|
|
297
|
+
logDiagnostic(this.#options.logger, "transport.connecting", {
|
|
298
|
+
generation,
|
|
299
|
+
attempt: this.#physicalAttempts,
|
|
300
|
+
gatewayId: this.#options.gatewayId,
|
|
301
|
+
reconnect: this.#physicalAttempts > 1
|
|
302
|
+
})
|
|
303
|
+
const tls = resolveVerifiedTls(this.#options.tls)
|
|
304
|
+
let physical
|
|
305
|
+
try {
|
|
306
|
+
physical = (this.#options.createPhysicalSocket ?? createHubSocket)({host: this.#options.hub.host, port: this.#options.hub.port, tls})
|
|
307
|
+
} catch (error) {
|
|
308
|
+
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
309
|
+
generation,
|
|
310
|
+
attempt: this.#physicalAttempts,
|
|
311
|
+
gatewayId: this.#options.gatewayId,
|
|
312
|
+
...errorDiagnostics(error)
|
|
313
|
+
})
|
|
314
|
+
this.#scheduleReconnect(asError(error))
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
this.#physical = physical
|
|
318
|
+
if (!this.#referenced) physical.unref()
|
|
319
|
+
physical.setNoDelay(true)
|
|
320
|
+
physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
|
|
321
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
|
|
322
|
+
let queue = Promise.resolve()
|
|
323
|
+
let authenticated = false
|
|
324
|
+
let selected = false
|
|
325
|
+
/** @type {Error | undefined} */
|
|
326
|
+
let attemptError
|
|
327
|
+
const timer = setTimeout(() => physical.destroy(Object.assign(new Error("Reverse client TLS/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})), this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
328
|
+
timer.unref?.()
|
|
329
|
+
physical.once("secureConnect", () => {
|
|
330
|
+
const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
|
|
331
|
+
void writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
|
|
332
|
+
.catch((error) => physical.destroy(asError(error)))
|
|
333
|
+
})
|
|
334
|
+
physical.on("data", (chunk) => {
|
|
335
|
+
physical.pause()
|
|
336
|
+
queue = queue.then(async () => {
|
|
337
|
+
for (const frame of decoder.push(chunk)) {
|
|
338
|
+
if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
|
|
339
|
+
const detail = decodeV3Control(frame.payload)
|
|
340
|
+
throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
|
|
341
|
+
}
|
|
342
|
+
if (!authenticated) {
|
|
343
|
+
if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
344
|
+
const detail = decodeV3Control(frame.payload)
|
|
345
|
+
if (detail.version !== 3 || detail.role !== "client" || detail.connectionId !== this.#connectionId || detail.generation !== generation || detail.compression !== ZSTD_CODEC || !validPeerWindow(detail.initialWindow)) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
346
|
+
authenticated = true
|
|
347
|
+
this.#codec = ZSTD_CODEC
|
|
348
|
+
this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
|
|
349
|
+
await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control({gatewayId: this.#options.gatewayId})))
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
if (!selected) {
|
|
353
|
+
if (frame.type !== V3FrameType.GATEWAY_SELECTED) throw fatalError("GATEWAY_SELECTION_FAILED")
|
|
354
|
+
const detail = decodeV3Control(frame.payload)
|
|
355
|
+
if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_SELECTION_FAILED")
|
|
356
|
+
selected = true
|
|
357
|
+
clearTimeout(timer)
|
|
358
|
+
this.#physicalConnects += 1
|
|
359
|
+
this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
|
|
360
|
+
this.#setState("ready")
|
|
361
|
+
logDiagnostic(this.#options.logger, "transport.connected", {
|
|
362
|
+
generation,
|
|
363
|
+
attempt: this.#physicalAttempts,
|
|
364
|
+
gatewayId: this.#options.gatewayId,
|
|
365
|
+
physicalConnects: this.#physicalConnects,
|
|
366
|
+
reconnect: this.#physicalConnects > 1
|
|
367
|
+
})
|
|
368
|
+
if (this.#physicalConnects > 1) {
|
|
369
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
|
|
370
|
+
generation,
|
|
371
|
+
attempt: this.#reconnectAttempts,
|
|
372
|
+
gatewayId: this.#options.gatewayId,
|
|
373
|
+
physicalConnects: this.#physicalConnects
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
this.#resolveConnect?.()
|
|
377
|
+
this.#settleConnect()
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
await this.#handleFrame(frame)
|
|
381
|
+
}
|
|
382
|
+
}).catch((error) => {
|
|
383
|
+
attemptError = asError(error)
|
|
384
|
+
if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
|
|
385
|
+
physical.destroy(attemptError)
|
|
386
|
+
}).finally(() => { if (!physical.destroyed) physical.resume() })
|
|
387
|
+
})
|
|
388
|
+
physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
|
|
389
|
+
physical.once("close", () => {
|
|
390
|
+
clearTimeout(timer)
|
|
391
|
+
void queue.finally(() => {
|
|
392
|
+
if (this.#physical !== physical) return
|
|
393
|
+
this.#physical = undefined
|
|
394
|
+
this.#codec = undefined
|
|
395
|
+
if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
|
|
396
|
+
const lost = new SocketductSessionLostError("Reverse gateway transport was lost; operation outcome may be uncertain", "GATEWAY_DISCONNECTED")
|
|
397
|
+
if (!this.#closing) {
|
|
398
|
+
logDiagnostic(this.#options.logger, "transport.disconnected", {
|
|
399
|
+
generation,
|
|
400
|
+
gatewayId: this.#options.gatewayId,
|
|
401
|
+
virtualStreams: this.#streams.size,
|
|
402
|
+
...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
for (const stream of [...this.#streams.values()]) stream.socket._receiveError(lost)
|
|
406
|
+
if (this.#closing) return
|
|
407
|
+
if (this.#fatal) {
|
|
408
|
+
const terminalError = attemptError ?? lost
|
|
409
|
+
this.#fatalError = terminalError
|
|
410
|
+
this.#setState("closed", terminalError)
|
|
411
|
+
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
412
|
+
generation,
|
|
413
|
+
attempt: this.#physicalAttempts,
|
|
414
|
+
gatewayId: this.#options.gatewayId,
|
|
415
|
+
...errorDiagnostics(terminalError, "GATEWAY_DISCONNECTED")
|
|
416
|
+
})
|
|
417
|
+
this.#rejectConnect?.(terminalError)
|
|
418
|
+
this.#settleConnect()
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
if (this.#warm || this.#options.continuousReconnect !== false) this.#scheduleReconnect(attemptError)
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
|
|
427
|
+
async #handleFrame(frame) {
|
|
428
|
+
if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
|
|
429
|
+
if (frame.type === V3FrameType.PONG) return
|
|
430
|
+
const stream = this.#streams.get(frame.streamId)
|
|
431
|
+
if (stream === undefined) {
|
|
432
|
+
if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if (frame.type === V3FrameType.OPENED) {
|
|
436
|
+
const detail = decodeV3Control(frame.payload)
|
|
437
|
+
if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
|
|
438
|
+
stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
|
|
439
|
+
stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
|
|
440
|
+
stream.state = "open"
|
|
441
|
+
logDiagnostic(this.#options.logger, "virtual_stream.opened", {
|
|
442
|
+
streamId: stream.id,
|
|
443
|
+
generation: this.#generation,
|
|
444
|
+
gatewayId: this.#options.gatewayId,
|
|
445
|
+
recovered: false
|
|
446
|
+
})
|
|
447
|
+
stream.socket._markConnected()
|
|
448
|
+
this.#progressPendingWrites()
|
|
449
|
+
await this.#pumpStream(stream)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
if (frame.type === V3FrameType.DATA) {
|
|
453
|
+
if (stream.remoteEnded) throw new Error("Reverse client received DATA after END")
|
|
454
|
+
const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
|
|
455
|
+
if (data.length === 0 || data.length > this.#maxFrameBytes() || frame.sequence !== stream.receiveSequence + data.length || frame.sequence - stream.receiveAck > this.#initialWindow()) throw new Error("Invalid reverse client DATA sequence or window")
|
|
456
|
+
stream.receiveSequence = frame.sequence
|
|
457
|
+
const canContinue = stream.socket._receive(data)
|
|
458
|
+
if (canContinue) this.#ackReceived(stream, frame.sequence)
|
|
459
|
+
else stream.pendingReceiveAck = frame.sequence
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
|
|
463
|
+
if (frame.type === V3FrameType.WINDOW) { this.#addWindow(stream, frame.sequence); return }
|
|
464
|
+
if (frame.type === V3FrameType.END) {
|
|
465
|
+
if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client END")
|
|
466
|
+
stream.remoteEnded = true
|
|
467
|
+
stream.socket._receiveEnd()
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
if (frame.type === V3FrameType.CLOSE) {
|
|
471
|
+
if (frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client CLOSE")
|
|
472
|
+
await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.transmittedSequence, {})
|
|
473
|
+
stream.state = "closed"
|
|
474
|
+
logDiagnostic(this.#options.logger, "virtual_stream.closed", {
|
|
475
|
+
streamId: stream.id,
|
|
476
|
+
generation: this.#generation,
|
|
477
|
+
gatewayId: this.#options.gatewayId
|
|
478
|
+
})
|
|
479
|
+
this.#retireStream(stream, new SocketductStreamError("Reverse stream closed before pending writes completed", "ERR_STREAM_DESTROYED"))
|
|
480
|
+
stream.socket._receiveEnd()
|
|
481
|
+
if (!stream.socket.writableEnded && !stream.socket.destroyed) stream.socket.end()
|
|
482
|
+
this.#finishEnd(stream)
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
if (frame.type === V3FrameType.RESET) {
|
|
486
|
+
const detail = decodeV3Control(frame.payload)
|
|
487
|
+
const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
|
|
488
|
+
stream.state = "reset"
|
|
489
|
+
const error = new SocketductStreamError("Reverse gateway stream was reset", code)
|
|
490
|
+
stream.socket._receiveError(error)
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
throw new Error("Unsupported reverse client frame")
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** @param {ReverseClientStream} stream - Stream. */
|
|
497
|
+
async #pumpStream(stream) {
|
|
498
|
+
if (stream.pumping || stream.state === "opening" || !this.#streams.has(stream.id) || this.#state !== "ready") return
|
|
499
|
+
stream.pumping = true
|
|
500
|
+
try {
|
|
501
|
+
for (const chunk of stream.replay) {
|
|
502
|
+
if (chunk.sent) continue
|
|
503
|
+
if (chunk.data.length > stream.sendWindow) break
|
|
504
|
+
const encoded = await encodeV3Data(chunk.data, this.#codec, stream.compressor, this.#options.compressionLimits)
|
|
505
|
+
if (!this.#streams.has(stream.id) || this.#state !== "ready") return
|
|
506
|
+
this.#rawDataBytes += encoded.rawBytes
|
|
507
|
+
this.#wireDataBytes += encoded.wireBytes
|
|
508
|
+
const sent = await writeSocket(/** @type {import('node:tls').TLSSocket} */ (this.#physical), encodeV3Frame({type: V3FrameType.DATA, streamId: stream.id, sequence: chunk.sequence, flags: encoded.flags}, encoded.payload), () => { stream.transmittedSequence = chunk.sequence })
|
|
509
|
+
if (!sent) return
|
|
510
|
+
chunk.sent = true
|
|
511
|
+
stream.sendWindow -= chunk.data.length
|
|
512
|
+
}
|
|
513
|
+
if (stream.localEnded && !stream.endSent && stream.replay.every((chunk) => chunk.sent)) {
|
|
514
|
+
stream.endSent = true
|
|
515
|
+
stream.state = "half-closed"
|
|
516
|
+
await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
|
|
517
|
+
this.#finishEnd(stream)
|
|
518
|
+
}
|
|
519
|
+
} finally { stream.pumping = false }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** @param {ReverseClientStream} stream - Stream with an active application write. */
|
|
523
|
+
async #progressWrite(stream) {
|
|
524
|
+
const pending = stream.pendingWrite
|
|
525
|
+
if (pending === undefined || stream.progressingWrite || stream.state === "opening") return
|
|
526
|
+
stream.progressingWrite = true
|
|
527
|
+
try {
|
|
528
|
+
if (pending.offset < pending.data.length && this.#streams.get(stream.id) === stream) {
|
|
529
|
+
const available = this.#writeCapacity(stream)
|
|
530
|
+
if (available <= 0) return
|
|
531
|
+
const chunk = Buffer.from(pending.data.subarray(pending.offset, Math.min(pending.data.length, pending.offset + available)))
|
|
532
|
+
try {
|
|
533
|
+
if (this.#options.aggregateBudget?.reserveMemory !== undefined) this.#options.aggregateBudget.reserveMemory(chunk.length)
|
|
534
|
+
else this.#options.aggregateBudget?.reserveData(chunk.length)
|
|
535
|
+
} catch (error) {
|
|
536
|
+
this.#finishWrite(stream, asError(error))
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
pending.offset += chunk.length
|
|
540
|
+
stream.sendSequence += chunk.length
|
|
541
|
+
stream.replay.push({sequence: stream.sendSequence, data: chunk, sent: false})
|
|
542
|
+
stream.bufferedBytes += chunk.length
|
|
543
|
+
this.#bufferedBytes += chunk.length
|
|
544
|
+
}
|
|
545
|
+
await this.#pumpStream(stream)
|
|
546
|
+
if (pending.offset === pending.data.length && stream.pendingWrite === pending) this.#finishWrite(stream)
|
|
547
|
+
} catch (error) {
|
|
548
|
+
this.#finishWrite(stream, asError(error))
|
|
549
|
+
} finally {
|
|
550
|
+
stream.progressingWrite = false
|
|
551
|
+
this.#progressPendingWrites()
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** @param {ReverseClientStream} stream - Stream. @returns {number} Retained-byte headroom for its next frame. */
|
|
556
|
+
#writeCapacity(stream) {
|
|
557
|
+
return Math.max(0, Math.min(
|
|
558
|
+
this.#maxFrameBytes(),
|
|
559
|
+
stream.sendWindowLimit,
|
|
560
|
+
(this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) - stream.bufferedBytes,
|
|
561
|
+
(this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES) - this.#bufferedBytes
|
|
562
|
+
))
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Starts one bounded pending-write slice using rotating stream priority. */
|
|
566
|
+
#progressPendingWrites() {
|
|
567
|
+
const streams = [...this.#streams.values()]
|
|
568
|
+
if (streams.length === 0) return
|
|
569
|
+
const previousIndex = streams.findIndex((stream) => stream.id === this.#lastProgressedStreamId)
|
|
570
|
+
const startIndex = previousIndex === -1 ? 0 : (previousIndex + 1) % streams.length
|
|
571
|
+
for (let offset = 0; offset < streams.length; offset += 1) {
|
|
572
|
+
const stream = /** @type {ReverseClientStream} */ (streams[(startIndex + offset) % streams.length])
|
|
573
|
+
const pending = stream.pendingWrite
|
|
574
|
+
if (pending === undefined || stream.progressingWrite || stream.state === "opening" || (pending.offset < pending.data.length && this.#writeCapacity(stream) <= 0)) continue
|
|
575
|
+
this.#lastProgressedStreamId = stream.id
|
|
576
|
+
void this.#progressWrite(stream)
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** @param {ReverseClientStream} stream - Stream. @param {Error} [error] - Write failure. */
|
|
582
|
+
#finishWrite(stream, error) {
|
|
583
|
+
const pending = stream.pendingWrite
|
|
584
|
+
if (pending === undefined) return
|
|
585
|
+
stream.pendingWrite = undefined
|
|
586
|
+
pending.callback(error)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** @param {ReverseClientStream} stream - Stream. @param {Error} [error] - Terminal send error. */
|
|
590
|
+
#finishEnd(stream, error) {
|
|
591
|
+
const callback = stream.endCallback
|
|
592
|
+
stream.endCallback = undefined
|
|
593
|
+
callback?.(error)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** @param {ReverseClientStream} stream - Terminal stream. @param {Error} [pendingWriteError] - Pending write failure. */
|
|
597
|
+
#retireStream(stream, pendingWriteError = new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")) {
|
|
598
|
+
if (!this.#streams.delete(stream.id)) return
|
|
599
|
+
this.#bufferedBytes = Math.max(0, this.#bufferedBytes - stream.bufferedBytes)
|
|
600
|
+
this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
|
|
601
|
+
if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(stream.bufferedBytes)
|
|
602
|
+
stream.replay = []
|
|
603
|
+
stream.bufferedBytes = 0
|
|
604
|
+
this.#finishWrite(stream, pendingWriteError)
|
|
605
|
+
this.#progressPendingWrites()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** @param {ReverseClientStream} stream - Stream. @param {number} sequence - ACK. */
|
|
609
|
+
#acknowledge(stream, sequence) {
|
|
610
|
+
if (!Number.isSafeInteger(sequence) || sequence < stream.sendAck || sequence > stream.sendSequence) throw new Error("Invalid reverse client ACK")
|
|
611
|
+
if (sequence === stream.sendAck) return
|
|
612
|
+
const delta = sequence - stream.sendAck
|
|
613
|
+
stream.sendAck = sequence
|
|
614
|
+
let released = 0
|
|
615
|
+
stream.replay = stream.replay.filter((chunk) => { if (chunk.sequence <= sequence) { released += chunk.data.length; return false } return true })
|
|
616
|
+
stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
|
|
617
|
+
this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
|
|
618
|
+
this.#options.aggregateBudget?.releaseMemory(released)
|
|
619
|
+
if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(released)
|
|
620
|
+
stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
|
|
621
|
+
this.#progressPendingWrites()
|
|
622
|
+
void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** @param {ReverseClientStream} stream - Stream. @param {number} credit - Credit. */
|
|
626
|
+
#addWindow(stream, credit) {
|
|
627
|
+
if (!Number.isSafeInteger(credit) || credit <= 0 || credit > stream.sendWindowLimit) throw new Error("Invalid reverse client WINDOW")
|
|
628
|
+
stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + credit)
|
|
629
|
+
void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** @param {ReverseClientStream} stream - Stream. @param {number} sequence - Receive ack. */
|
|
633
|
+
#ackReceived(stream, sequence) {
|
|
634
|
+
if (sequence <= stream.receiveAck) return
|
|
635
|
+
stream.receiveAck = sequence
|
|
636
|
+
void this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
|
|
640
|
+
async #sendControl(type, streamId, sequence, body) {
|
|
641
|
+
const physical = this.#physical
|
|
642
|
+
if (physical === undefined || physical.destroyed) throw new SocketductSessionLostError("Reverse gateway transport is unavailable", "GATEWAY_DISCONNECTED")
|
|
643
|
+
return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
#scheduleReconnect(/** @type {Error | undefined} */ error) {
|
|
647
|
+
if (this.#closing || this.#fatal || this.#reconnectTimer !== undefined) return
|
|
648
|
+
this.#reconnectAttempts += 1
|
|
649
|
+
this.#setState("reconnecting")
|
|
650
|
+
const delay = this.#reconnectDelay
|
|
651
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_scheduled", {
|
|
652
|
+
generation: this.#generation,
|
|
653
|
+
attempt: this.#reconnectAttempts,
|
|
654
|
+
delayMs: delay,
|
|
655
|
+
gatewayId: this.#options.gatewayId,
|
|
656
|
+
...errorDiagnostics(error, "GATEWAY_DISCONNECTED")
|
|
657
|
+
})
|
|
658
|
+
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
|
|
659
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
660
|
+
this.#reconnectTimer = undefined
|
|
661
|
+
if (this.#connectPromise === undefined) {
|
|
662
|
+
this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
|
|
663
|
+
this.#connectPromise.catch(() => {})
|
|
664
|
+
}
|
|
665
|
+
this.#connectPhysical()
|
|
666
|
+
}, delay)
|
|
667
|
+
if (!this.#referenced) this.#reconnectTimer.unref?.()
|
|
668
|
+
if (this.#physicalConnects === 0 && this.#options.continuousReconnect === false) {
|
|
669
|
+
this.#rejectConnect?.(error ?? new Error("Reverse hub connection failed"))
|
|
670
|
+
this.#settleConnect()
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
#settleConnect() { this.#connectPromise = undefined; this.#resolveConnect = undefined; this.#rejectConnect = undefined }
|
|
675
|
+
#setState(/** @type {'configured' | 'connecting' | 'ready' | 'reconnecting' | 'closed'} */ state, /** @type {Error | undefined} */ error = undefined) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state, error) }
|
|
676
|
+
#initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
|
|
677
|
+
#maxFrameBytes() { return Math.min(this.#options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, getMaxDecompressedBytes(this.#options.compressionLimits), 64 * 1024) }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** @param {ReverseGatewayConnectionOptions} options - Options. */
|
|
681
|
+
function validateOptions(options) {
|
|
682
|
+
if (typeof options !== "object" || options === null || typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId)) throw new TypeError("Reverse gateway connection requires a valid gatewayId")
|
|
683
|
+
if (options.connectionId !== undefined && (typeof options.connectionId !== "string" || !STABLE_IDENTIFIER.test(options.connectionId))) throw new TypeError("Reverse gateway connectionId must be 8-128 URL-safe characters")
|
|
684
|
+
if (typeof options.hub !== "object" || options.hub === null || typeof options.hub.host !== "string" || options.hub.host.length === 0 || !Number.isInteger(options.hub.port) || options.hub.port < 1 || options.hub.port > 65_535 || typeof options.hub.token !== "string" || options.hub.token.length === 0) throw new TypeError("Reverse gateway connection requires hub host, port and token")
|
|
685
|
+
resolveVerifiedTls(options.tls)
|
|
686
|
+
if (!zstdAvailable()) throw new Error("Reverse gateway mode requires Node.js zstd support")
|
|
687
|
+
getMaxDecompressedBytes(options.compressionLimits)
|
|
688
|
+
const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
|
|
689
|
+
const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
|
|
690
|
+
if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
|
|
691
|
+
positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
|
|
692
|
+
positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
|
|
693
|
+
positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
|
|
694
|
+
if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. */
|
|
698
|
+
function installCallback(socket, callback) {
|
|
699
|
+
let settled = false
|
|
700
|
+
const cleanup = () => { socket.off("connect", onConnect); socket.off("error", onError) }
|
|
701
|
+
const onConnect = () => { if (settled) return; settled = true; cleanup(); callback(null, socket) }
|
|
702
|
+
const onError = (/** @type {Error} */ error) => { if (settled) return; settled = true; cleanup(); callback(error, socket) }
|
|
703
|
+
socket.once("connect", onConnect)
|
|
704
|
+
socket.once("error", onError)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function fatalError(/** @type {string} */ code) { return Object.assign(new Error("Reverse gateway client was rejected by the hub"), {code, fatal: true}) }
|
|
708
|
+
function connectionError(/** @type {string} */ code) {
|
|
709
|
+
if (code === "HUB_SHUTDOWN" || code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") return Object.assign(new Error("Selected reverse gateway is temporarily unavailable"), {code, fatal: false})
|
|
710
|
+
return fatalError(code)
|
|
711
|
+
}
|
|
712
|
+
/** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
|
|
713
|
+
function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0 }
|
|
714
|
+
function closedError() { return Object.assign(new Error("Reverse gateway connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}) }
|
|
715
|
+
function isFatalTransportError(/** @type {Error} */ error) {
|
|
716
|
+
const code = /** @type {{code?: unknown}} */ (error).code
|
|
717
|
+
return typeof code === "string" && (code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_") || code.includes("CERT") || code.includes("VERIFY") || code.includes("SELF_SIGNED") || code.includes("ISSUER"))
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** @param {ReverseGatewayConnectionOptions} options - Options. */
|
|
721
|
+
export function createReverseGatewayConnection(options) { return new ReverseGatewayConnection(options) }
|