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,663 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-jsdoc, jsdoc/require-param, jsdoc/require-property-description, jsdoc/require-returns */
|
|
2
|
+
import {EventEmitter} from "node:events"
|
|
3
|
+
import {createConnection as netConnect} from "node:net"
|
|
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 {
|
|
10
|
+
asError,
|
|
11
|
+
createHubSocket,
|
|
12
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
13
|
+
DEFAULT_FRAME_BYTES,
|
|
14
|
+
DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
|
|
15
|
+
DEFAULT_MAX_STREAMS,
|
|
16
|
+
DEFAULT_WINDOW,
|
|
17
|
+
GATEWAY_ID,
|
|
18
|
+
MAX_CONTROL_BYTES,
|
|
19
|
+
parseTarget,
|
|
20
|
+
positiveInteger,
|
|
21
|
+
resolveVerifiedTls,
|
|
22
|
+
STABLE_IDENTIFIER,
|
|
23
|
+
writeSocket
|
|
24
|
+
} from "./common.js"
|
|
25
|
+
|
|
26
|
+
const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
|
|
27
|
+
const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} OutboundReverseGatewayOptions
|
|
31
|
+
* @property {string} gatewayId - Stable configured gateway identity.
|
|
32
|
+
* @property {string} connectionId - Stable process identity retained across reconnects.
|
|
33
|
+
* @property {{host: string, port: number, token: string}} hub - Server3 hub endpoint and bearer credential.
|
|
34
|
+
* @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string}} tls - Mandatory verified outer TLS.
|
|
35
|
+
* @property {(target: {host: string, port: number}) => boolean} authorizeTarget - Local deny-by-default target policy.
|
|
36
|
+
* @property {(target: {host: string, port: number}) => import('node:net').Socket} [createTargetConnection] - Local target socket factory.
|
|
37
|
+
* @property {number} [initialWindowBytes] - Per-stream flow-control window.
|
|
38
|
+
* @property {number} [maxFrameBytes] - Maximum uncompressed DATA frame.
|
|
39
|
+
* @property {number} [maxStreams] - Concurrent local target bound.
|
|
40
|
+
* @property {number} [maxStreamBufferBytes] - Per-stream unacknowledged target output bound.
|
|
41
|
+
* @property {number} [maxTotalBufferBytes] - Gateway-wide unacknowledged target output bound.
|
|
42
|
+
* @property {number} [connectTimeoutMs] - TLS/auth/register deadline.
|
|
43
|
+
* @property {number} [targetConnectTimeoutMs] - Local target connection deadline.
|
|
44
|
+
* @property {number} [reconnectDelayMs] - Initial reconnect delay.
|
|
45
|
+
* @property {number} [maxReconnectDelayMs] - Maximum reconnect delay.
|
|
46
|
+
* @property {number} [initialGeneration] - Monotonic seed for controlled replacement; defaults to wall-clock microseconds.
|
|
47
|
+
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
|
|
48
|
+
* @property {(options: {host: string, port: number, tls: object}) => import('node:tls').TLSSocket} [createPhysicalSocket] - Test transport factory.
|
|
49
|
+
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
|
|
50
|
+
* @typedef {object} GatewayReplay
|
|
51
|
+
* @property {number} sequence
|
|
52
|
+
* @property {Buffer} data
|
|
53
|
+
* @property {boolean} sent
|
|
54
|
+
* @typedef {object} GatewayStream
|
|
55
|
+
* @property {number} id
|
|
56
|
+
* @property {string} identifier
|
|
57
|
+
* @property {{host: string, port: number}} target
|
|
58
|
+
* @property {import('node:net').Socket} targetSocket
|
|
59
|
+
* @property {'opening' | 'open' | 'half-closed'} state
|
|
60
|
+
* @property {number} receiveSequence
|
|
61
|
+
* @property {number} receiveAck
|
|
62
|
+
* @property {boolean} remoteEnded
|
|
63
|
+
* @property {number} sendSequence
|
|
64
|
+
* @property {number} sendAck
|
|
65
|
+
* @property {number} sendWindow
|
|
66
|
+
* @property {number} sendWindowLimit
|
|
67
|
+
* @property {number} bufferedBytes
|
|
68
|
+
* @property {GatewayReplay[]} replay
|
|
69
|
+
* @property {AdaptiveCompressor} compressor
|
|
70
|
+
* @property {Promise<void>} targetQueue
|
|
71
|
+
* @property {boolean} pumping
|
|
72
|
+
* @property {boolean} targetEnded
|
|
73
|
+
* @property {boolean} targetClosed
|
|
74
|
+
* @property {string | undefined} targetResetCode
|
|
75
|
+
* @property {boolean} endSent
|
|
76
|
+
* @property {ReturnType<typeof setTimeout> | undefined} openTimer
|
|
77
|
+
* @property {boolean} targetOpened
|
|
78
|
+
* @property {boolean} targetTerminalLogged
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/** One outbound home member carrying many hub-opened local target streams. */
|
|
82
|
+
export class OutboundReverseGateway extends EventEmitter {
|
|
83
|
+
/** @type {OutboundReverseGatewayOptions} */ #options
|
|
84
|
+
/** @type {import('node:tls').TLSSocket | undefined} */ #physical
|
|
85
|
+
/** @type {Map<number, GatewayStream>} */ #streams = new Map()
|
|
86
|
+
/** @type {number} */ #lastStreamId = 0
|
|
87
|
+
/** @type {'configured' | 'connecting' | 'registering' | 'ready' | 'reconnecting' | 'closed'} */ #state = "configured"
|
|
88
|
+
/** @type {number} */ #generation
|
|
89
|
+
/** @type {string | undefined} */ #codec
|
|
90
|
+
/** @type {boolean} */ #closing = false
|
|
91
|
+
/** @type {boolean} */ #fatal = false
|
|
92
|
+
/** @type {boolean} */ #referenced = true
|
|
93
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
|
|
94
|
+
/** @type {Promise<void> | undefined} */ #startPromise
|
|
95
|
+
/** @type {(() => void) | undefined} */ #resolveStart
|
|
96
|
+
/** @type {((error: Error) => void) | undefined} */ #rejectStart
|
|
97
|
+
/** @type {number} */ #reconnectDelay
|
|
98
|
+
/** @type {number} */ #sendWindowLimit
|
|
99
|
+
/** @type {number} */ #physicalConnects = 0
|
|
100
|
+
/** @type {number} */ #physicalAttempts = 0
|
|
101
|
+
/** @type {number} */ #reconnectAttempts = 0
|
|
102
|
+
/** @type {number} */ #targetOpens = 0
|
|
103
|
+
/** @type {number} */ #targetOpenFailures = 0
|
|
104
|
+
/** @type {number} */ #bufferedBytes = 0
|
|
105
|
+
/** @type {number} */ #rawDataBytes = 0
|
|
106
|
+
/** @type {number} */ #wireDataBytes = 0
|
|
107
|
+
|
|
108
|
+
/** @param {OutboundReverseGatewayOptions} options - Identity, hub, TLS and local policy. */
|
|
109
|
+
constructor(options) {
|
|
110
|
+
super()
|
|
111
|
+
validateOptions(options)
|
|
112
|
+
this.#options = options
|
|
113
|
+
this.#generation = options.initialGeneration ?? Date.now() * 1000
|
|
114
|
+
this.#reconnectDelay = options.reconnectDelayMs ?? 250
|
|
115
|
+
this.#sendWindowLimit = this.#initialWindow()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Starts and resolves after authenticated registration. */
|
|
119
|
+
start() {
|
|
120
|
+
if (this.#startPromise !== undefined) return this.#startPromise
|
|
121
|
+
if (this.#closing) return Promise.reject(closedError())
|
|
122
|
+
this.#startPromise = new Promise((resolve, reject) => { this.#resolveStart = resolve; this.#rejectStart = reject })
|
|
123
|
+
this.#connect()
|
|
124
|
+
return this.#startPromise
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Alias for lifecycle symmetry with client transports. */
|
|
128
|
+
connect() { return this.start() }
|
|
129
|
+
|
|
130
|
+
/** Privacy-safe runtime state and compression accounting. */
|
|
131
|
+
status() {
|
|
132
|
+
return {
|
|
133
|
+
physicalState: this.#state,
|
|
134
|
+
connectionGeneration: this.#generation,
|
|
135
|
+
physicalConnects: this.#physicalConnects,
|
|
136
|
+
reconnectAttempts: this.#reconnectAttempts,
|
|
137
|
+
reconnectSuccesses: Math.max(0, this.#physicalConnects - 1),
|
|
138
|
+
virtualStreams: this.#streams.size,
|
|
139
|
+
activeTargets: [...this.#streams.values()].filter((stream) => stream.state !== "opening").length,
|
|
140
|
+
targetOpens: this.#targetOpens,
|
|
141
|
+
targetOpenFailures: this.#targetOpenFailures,
|
|
142
|
+
queuedBytes: this.#bufferedBytes,
|
|
143
|
+
spoolBytes: 0,
|
|
144
|
+
spoolCleanupFailures: 0,
|
|
145
|
+
compression: this.#codec ?? null,
|
|
146
|
+
rawDataBytes: this.#rawDataBytes,
|
|
147
|
+
wireDataBytes: this.#wireDataBytes,
|
|
148
|
+
compressionBytesSaved: Math.max(0, this.#rawDataBytes - this.#wireDataBytes)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Current local TLS address for socket-shaped metadata. */
|
|
153
|
+
physicalAddress() {
|
|
154
|
+
return {address: this.#physical?.localAddress, port: this.#physical?.localPort}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ref() { this.#referenced = true; this.#physical?.ref(); this.#reconnectTimer?.ref?.(); return this }
|
|
158
|
+
unref() { this.#referenced = false; this.#physical?.unref(); this.#reconnectTimer?.unref?.(); return this }
|
|
159
|
+
|
|
160
|
+
/** Gracefully retires this member and every local target. */
|
|
161
|
+
async close() {
|
|
162
|
+
if (this.#closing) return
|
|
163
|
+
this.#closing = true
|
|
164
|
+
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
165
|
+
this.#reconnectTimer = undefined
|
|
166
|
+
this.#setState("closed")
|
|
167
|
+
for (const stream of [...this.#streams.values()]) this.#destroyStream(stream)
|
|
168
|
+
const physical = this.#physical
|
|
169
|
+
this.#physical = undefined
|
|
170
|
+
if (physical !== undefined && !physical.destroyed) {
|
|
171
|
+
await writeSocket(physical, encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({reason: "gateway_shutdown"}))).catch(() => false)
|
|
172
|
+
physical.destroy()
|
|
173
|
+
}
|
|
174
|
+
this.#rejectStart?.(closedError())
|
|
175
|
+
this.#settleStart()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#connect() {
|
|
179
|
+
if (this.#closing || this.#fatal) return
|
|
180
|
+
this.#sendWindowLimit = this.#initialWindow()
|
|
181
|
+
this.#setState(this.#physicalConnects === 0 ? "connecting" : "reconnecting")
|
|
182
|
+
this.#generation += 1
|
|
183
|
+
this.#physicalAttempts += 1
|
|
184
|
+
const generation = this.#generation
|
|
185
|
+
logDiagnostic(this.#options.logger, "transport.connecting", {
|
|
186
|
+
generation,
|
|
187
|
+
attempt: this.#physicalAttempts,
|
|
188
|
+
gatewayId: this.#options.gatewayId,
|
|
189
|
+
reconnect: this.#physicalAttempts > 1
|
|
190
|
+
})
|
|
191
|
+
const tls = resolveVerifiedTls(this.#options.tls)
|
|
192
|
+
let physical
|
|
193
|
+
try {
|
|
194
|
+
physical = (this.#options.createPhysicalSocket ?? createHubSocket)({host: this.#options.hub.host, port: this.#options.hub.port, tls})
|
|
195
|
+
} catch (error) {
|
|
196
|
+
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
197
|
+
generation,
|
|
198
|
+
attempt: this.#physicalAttempts,
|
|
199
|
+
gatewayId: this.#options.gatewayId,
|
|
200
|
+
...errorDiagnostics(error)
|
|
201
|
+
})
|
|
202
|
+
this.#scheduleReconnect(error)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
this.#physical = physical
|
|
206
|
+
if (!this.#referenced) physical.unref()
|
|
207
|
+
physical.setNoDelay(true)
|
|
208
|
+
physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
|
|
209
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
|
|
210
|
+
let queue = Promise.resolve()
|
|
211
|
+
/** @type {Error | undefined} */
|
|
212
|
+
let attemptError
|
|
213
|
+
let authenticated = false
|
|
214
|
+
let registered = false
|
|
215
|
+
const timer = setTimeout(() => physical.destroy(Object.assign(new Error("Reverse gateway connect/auth/register timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})), this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
216
|
+
timer.unref?.()
|
|
217
|
+
physical.once("secureConnect", () => {
|
|
218
|
+
if (this.#physical !== physical || physical.destroyed) return
|
|
219
|
+
const auth = encodeV3Control({
|
|
220
|
+
role: "gateway",
|
|
221
|
+
token: this.#options.hub.token,
|
|
222
|
+
versions: [3],
|
|
223
|
+
connectionId: this.#options.connectionId,
|
|
224
|
+
generation,
|
|
225
|
+
compression: availableCodecs()
|
|
226
|
+
})
|
|
227
|
+
void writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, auth))
|
|
228
|
+
})
|
|
229
|
+
physical.on("data", (chunk) => {
|
|
230
|
+
physical.pause()
|
|
231
|
+
queue = queue.then(async () => {
|
|
232
|
+
for (const frame of decoder.push(chunk)) {
|
|
233
|
+
if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
|
|
234
|
+
const detail = decodeV3Control(frame.payload)
|
|
235
|
+
throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
|
|
236
|
+
}
|
|
237
|
+
if (!authenticated) {
|
|
238
|
+
if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
239
|
+
const detail = decodeV3Control(frame.payload)
|
|
240
|
+
if (detail.version !== 3 || detail.role !== "gateway" || detail.connectionId !== this.#options.connectionId || detail.generation !== generation || detail.compression !== ZSTD_CODEC || !validPeerWindow(detail.initialWindow)) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
241
|
+
authenticated = true
|
|
242
|
+
this.#codec = ZSTD_CODEC
|
|
243
|
+
this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
|
|
244
|
+
this.#setState("registering")
|
|
245
|
+
await this.#sendControl(V3FrameType.GATEWAY_REGISTER, 0, generation, {gatewayId: this.#options.gatewayId})
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
if (!registered) {
|
|
249
|
+
if (frame.type !== V3FrameType.GATEWAY_REGISTERED) throw fatalError("GATEWAY_REGISTRATION_FAILED")
|
|
250
|
+
const detail = decodeV3Control(frame.payload)
|
|
251
|
+
if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_REGISTRATION_FAILED")
|
|
252
|
+
registered = true
|
|
253
|
+
clearTimeout(timer)
|
|
254
|
+
this.#physicalConnects += 1
|
|
255
|
+
this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
|
|
256
|
+
this.#setState("ready")
|
|
257
|
+
logDiagnostic(this.#options.logger, "transport.connected", {
|
|
258
|
+
generation,
|
|
259
|
+
attempt: this.#physicalAttempts,
|
|
260
|
+
gatewayId: this.#options.gatewayId,
|
|
261
|
+
physicalConnects: this.#physicalConnects,
|
|
262
|
+
reconnect: this.#physicalConnects > 1
|
|
263
|
+
})
|
|
264
|
+
if (this.#physicalConnects > 1) {
|
|
265
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
|
|
266
|
+
generation,
|
|
267
|
+
attempt: this.#reconnectAttempts,
|
|
268
|
+
gatewayId: this.#options.gatewayId,
|
|
269
|
+
physicalConnects: this.#physicalConnects
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
this.#resolveStart?.()
|
|
273
|
+
this.#settleStart()
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
await this.#handleFrame(frame)
|
|
277
|
+
}
|
|
278
|
+
}).catch((error) => {
|
|
279
|
+
attemptError = asError(error)
|
|
280
|
+
if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
|
|
281
|
+
physical.destroy(attemptError)
|
|
282
|
+
}).finally(() => {
|
|
283
|
+
if (!physical.destroyed) physical.resume()
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
|
|
287
|
+
physical.once("close", () => {
|
|
288
|
+
clearTimeout(timer)
|
|
289
|
+
void queue.finally(() => {
|
|
290
|
+
if (this.#physical !== physical) return
|
|
291
|
+
this.#physical = undefined
|
|
292
|
+
this.#codec = undefined
|
|
293
|
+
if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
|
|
294
|
+
if (!this.#closing) {
|
|
295
|
+
logDiagnostic(this.#options.logger, "transport.disconnected", {
|
|
296
|
+
generation,
|
|
297
|
+
gatewayId: this.#options.gatewayId,
|
|
298
|
+
virtualStreams: this.#streams.size,
|
|
299
|
+
...errorDiagnostics(attemptError, "TRANSPORT_CLOSED")
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "GATEWAY_DISCONNECTED")
|
|
303
|
+
this.#lastStreamId = 0
|
|
304
|
+
if (this.#closing) return
|
|
305
|
+
if (this.#fatal) {
|
|
306
|
+
this.#setState("closed")
|
|
307
|
+
const failure = attemptError ?? new Error("Reverse gateway registration failed")
|
|
308
|
+
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
309
|
+
generation,
|
|
310
|
+
attempt: this.#physicalAttempts,
|
|
311
|
+
gatewayId: this.#options.gatewayId,
|
|
312
|
+
...errorDiagnostics(failure, "GATEWAY_REGISTRATION_FAILED")
|
|
313
|
+
})
|
|
314
|
+
this.#rejectStart?.(failure)
|
|
315
|
+
this.#settleStart()
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
this.#scheduleReconnect(attemptError)
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
|
|
324
|
+
async #handleFrame(frame) {
|
|
325
|
+
if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
|
|
326
|
+
if (frame.type === V3FrameType.PONG) return
|
|
327
|
+
if (frame.type === V3FrameType.OPEN) { await this.#openStream(frame); return }
|
|
328
|
+
const stream = this.#streams.get(frame.streamId)
|
|
329
|
+
if (stream === undefined) {
|
|
330
|
+
if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if (stream.state === "opening" && frame.type !== V3FrameType.RESET) throw new Error("Reverse stream received data before target readiness")
|
|
334
|
+
if (frame.type === V3FrameType.DATA) {
|
|
335
|
+
if (stream.remoteEnded) throw new Error("Reverse stream received DATA after END")
|
|
336
|
+
const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
|
|
337
|
+
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 stream DATA sequence or window")
|
|
338
|
+
try {
|
|
339
|
+
await writeTarget(stream.targetSocket, data)
|
|
340
|
+
} catch {
|
|
341
|
+
this.#resetStream(stream, "TARGET_CLOSED")
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
stream.receiveSequence = frame.sequence
|
|
345
|
+
stream.receiveAck = frame.sequence
|
|
346
|
+
await this.#sendControl(V3FrameType.ACK, stream.id, frame.sequence, {})
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
|
|
350
|
+
if (frame.type === V3FrameType.WINDOW) { this.#addWindow(stream, frame.sequence); return }
|
|
351
|
+
if (frame.type === V3FrameType.END) {
|
|
352
|
+
if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse stream END")
|
|
353
|
+
stream.remoteEnded = true
|
|
354
|
+
stream.state = "half-closed"
|
|
355
|
+
stream.targetSocket.end()
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) { this.#destroyStream(stream); return }
|
|
359
|
+
throw new Error("Unsupported reverse gateway stream frame")
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub OPEN. */
|
|
363
|
+
async #openStream(frame) {
|
|
364
|
+
if (frame.sequence !== 0 || frame.streamId % 2 !== 0 || frame.streamId <= this.#lastStreamId) throw new Error("Invalid reverse gateway stream ID")
|
|
365
|
+
this.#lastStreamId = frame.streamId
|
|
366
|
+
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) {
|
|
367
|
+
logDiagnostic(this.#options.logger, "reverse_gateway.target_open_failed", {
|
|
368
|
+
streamId: frame.streamId,
|
|
369
|
+
generation: this.#generation,
|
|
370
|
+
gatewayId: this.#options.gatewayId,
|
|
371
|
+
...errorDiagnostics({code: "GATEWAY_STREAM_LIMIT"})
|
|
372
|
+
})
|
|
373
|
+
await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "GATEWAY_STREAM_LIMIT"})
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
const request = decodeV3Control(frame.payload)
|
|
377
|
+
const target = parseTarget(request.target)
|
|
378
|
+
if (target === undefined || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || !validPeerWindow(request.initialWindow) || this.#options.authorizeTarget(target) !== true) {
|
|
379
|
+
const code = target === undefined ? "INVALID_STREAM_OPEN" : "TARGET_UNAUTHORIZED"
|
|
380
|
+
logDiagnostic(this.#options.logger, "reverse_gateway.target_open_failed", {
|
|
381
|
+
streamId: frame.streamId,
|
|
382
|
+
generation: this.#generation,
|
|
383
|
+
gatewayId: this.#options.gatewayId,
|
|
384
|
+
...errorDiagnostics({code})
|
|
385
|
+
})
|
|
386
|
+
await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code})
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
let targetSocket
|
|
390
|
+
try {
|
|
391
|
+
targetSocket = (this.#options.createTargetConnection ?? ((endpoint) => netConnect(endpoint)))(target)
|
|
392
|
+
} catch (error) {
|
|
393
|
+
this.#targetOpenFailures += 1
|
|
394
|
+
logDiagnostic(this.#options.logger, "reverse_gateway.target_open_failed", {
|
|
395
|
+
streamId: frame.streamId,
|
|
396
|
+
generation: this.#generation,
|
|
397
|
+
gatewayId: this.#options.gatewayId,
|
|
398
|
+
...errorDiagnostics(error, "TARGET_OPEN_FAILED")
|
|
399
|
+
})
|
|
400
|
+
await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_OPEN_FAILED"})
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
/** @type {GatewayStream} */
|
|
404
|
+
const stream = {
|
|
405
|
+
id: frame.streamId,
|
|
406
|
+
identifier: request.identifier,
|
|
407
|
+
target,
|
|
408
|
+
targetSocket,
|
|
409
|
+
state: "opening",
|
|
410
|
+
receiveSequence: 0,
|
|
411
|
+
receiveAck: 0,
|
|
412
|
+
remoteEnded: false,
|
|
413
|
+
sendSequence: 0,
|
|
414
|
+
sendAck: 0,
|
|
415
|
+
sendWindow: Math.min(this.#sendWindowLimit, request.initialWindow),
|
|
416
|
+
sendWindowLimit: Math.min(this.#sendWindowLimit, request.initialWindow),
|
|
417
|
+
bufferedBytes: 0,
|
|
418
|
+
replay: [],
|
|
419
|
+
compressor: new AdaptiveCompressor(),
|
|
420
|
+
targetQueue: Promise.resolve(),
|
|
421
|
+
pumping: false,
|
|
422
|
+
targetEnded: false,
|
|
423
|
+
targetClosed: false,
|
|
424
|
+
targetResetCode: undefined,
|
|
425
|
+
endSent: false,
|
|
426
|
+
openTimer: undefined,
|
|
427
|
+
targetOpened: false,
|
|
428
|
+
targetTerminalLogged: false
|
|
429
|
+
}
|
|
430
|
+
this.#streams.set(stream.id, stream)
|
|
431
|
+
stream.openTimer = setTimeout(() => this.#resetStream(stream, "TARGET_OPEN_TIMEOUT"), this.#options.targetConnectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
432
|
+
stream.openTimer.unref?.()
|
|
433
|
+
targetSocket.once("connect", () => {
|
|
434
|
+
if (!this.#streams.has(stream.id)) return
|
|
435
|
+
if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
|
|
436
|
+
stream.openTimer = undefined
|
|
437
|
+
stream.state = "open"
|
|
438
|
+
stream.targetOpened = true
|
|
439
|
+
this.#targetOpens += 1
|
|
440
|
+
logDiagnostic(this.#options.logger, "reverse_gateway.target_opened", {
|
|
441
|
+
streamId: stream.id,
|
|
442
|
+
generation: this.#generation,
|
|
443
|
+
gatewayId: this.#options.gatewayId
|
|
444
|
+
})
|
|
445
|
+
void this.#sendControl(V3FrameType.OPENED, stream.id, 0, {identifier: stream.identifier, initialWindow: this.#initialWindow()})
|
|
446
|
+
})
|
|
447
|
+
targetSocket.on("data", (chunk) => {
|
|
448
|
+
targetSocket.pause()
|
|
449
|
+
stream.targetQueue = stream.targetQueue.then(() => this.#retainTargetData(stream, chunk)).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
|
|
450
|
+
})
|
|
451
|
+
targetSocket.once("end", () => {
|
|
452
|
+
stream.targetQueue = stream.targetQueue.then(async () => {
|
|
453
|
+
if (!this.#streams.has(stream.id)) return
|
|
454
|
+
stream.targetEnded = true
|
|
455
|
+
await this.#pumpStream(stream)
|
|
456
|
+
}).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
|
|
457
|
+
})
|
|
458
|
+
targetSocket.once("error", () => {
|
|
459
|
+
if (!this.#streams.has(stream.id)) return
|
|
460
|
+
const code = stream.state === "opening" ? "TARGET_OPEN_FAILED" : "TARGET_CLOSED"
|
|
461
|
+
this.#targetOpenFailures += stream.state === "opening" ? 1 : 0
|
|
462
|
+
this.#logTargetTerminal(stream, code)
|
|
463
|
+
stream.targetQueue = stream.targetQueue.then(async () => {
|
|
464
|
+
if (!this.#streams.has(stream.id)) return
|
|
465
|
+
stream.targetResetCode = code
|
|
466
|
+
await this.#pumpStream(stream)
|
|
467
|
+
}).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
|
|
468
|
+
})
|
|
469
|
+
targetSocket.once("close", () => {
|
|
470
|
+
this.#logTargetTerminal(stream, "TARGET_CLOSED")
|
|
471
|
+
stream.targetQueue = stream.targetQueue.then(async () => {
|
|
472
|
+
if (!this.#streams.has(stream.id)) return
|
|
473
|
+
stream.targetClosed = true
|
|
474
|
+
await this.#pumpStream(stream)
|
|
475
|
+
}).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** @param {GatewayStream} stream - Stream. @param {Buffer} chunk - Target output. */
|
|
480
|
+
async #retainTargetData(stream, chunk) {
|
|
481
|
+
if (!this.#streams.has(stream.id) || stream.state === "opening") return
|
|
482
|
+
for (let offset = 0; offset < chunk.length; offset += this.#maxFrameBytes()) {
|
|
483
|
+
const data = Buffer.from(chunk.subarray(offset, Math.min(chunk.length, offset + this.#maxFrameBytes())))
|
|
484
|
+
if (stream.bufferedBytes + data.length > (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) || this.#bufferedBytes + data.length > (this.#options.maxTotalBufferBytes ?? DEFAULT_TOTAL_BUFFER_BYTES)) {
|
|
485
|
+
this.#resetStream(stream, "BUFFER_LIMIT")
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
stream.sendSequence += data.length
|
|
489
|
+
stream.replay.push({sequence: stream.sendSequence, data, sent: false})
|
|
490
|
+
stream.bufferedBytes += data.length
|
|
491
|
+
this.#bufferedBytes += data.length
|
|
492
|
+
}
|
|
493
|
+
await this.#pumpStream(stream)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** @param {GatewayStream} stream - Stream. */
|
|
497
|
+
async #pumpStream(stream) {
|
|
498
|
+
if (stream.pumping || !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))
|
|
509
|
+
if (!sent) return
|
|
510
|
+
chunk.sent = true
|
|
511
|
+
stream.sendWindow -= chunk.data.length
|
|
512
|
+
}
|
|
513
|
+
if (!stream.replay.some((chunk) => !chunk.sent)) {
|
|
514
|
+
if (stream.targetResetCode !== undefined) {
|
|
515
|
+
const code = stream.targetResetCode
|
|
516
|
+
stream.targetResetCode = undefined
|
|
517
|
+
await this.#sendControl(V3FrameType.RESET, stream.id, 0, {code})
|
|
518
|
+
this.#destroyStream(stream, code)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
if (stream.targetEnded && !stream.endSent) {
|
|
522
|
+
stream.endSent = true
|
|
523
|
+
await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
|
|
524
|
+
}
|
|
525
|
+
if (stream.targetClosed && (!stream.targetEnded || stream.endSent)) {
|
|
526
|
+
await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.sendSequence, {})
|
|
527
|
+
this.#destroyStream(stream)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
stream.pumping = false
|
|
532
|
+
if (this.#streams.has(stream.id) && !stream.replay.some((chunk) => !chunk.sent) && stream.bufferedBytes < (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) && this.#bufferedBytes < (this.#options.maxTotalBufferBytes ?? DEFAULT_TOTAL_BUFFER_BYTES)) stream.targetSocket.resume()
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** @param {GatewayStream} stream - Stream. @param {number} sequence - Cumulative ack. */
|
|
537
|
+
#acknowledge(stream, sequence) {
|
|
538
|
+
if (!Number.isSafeInteger(sequence) || sequence < stream.sendAck || sequence > stream.sendSequence) throw new Error("Invalid reverse gateway ACK")
|
|
539
|
+
if (sequence === stream.sendAck) return
|
|
540
|
+
const delta = sequence - stream.sendAck
|
|
541
|
+
stream.sendAck = sequence
|
|
542
|
+
let released = 0
|
|
543
|
+
stream.replay = stream.replay.filter((chunk) => { if (chunk.sequence <= sequence) { released += chunk.data.length; return false } return true })
|
|
544
|
+
stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
|
|
545
|
+
this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
|
|
546
|
+
stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
|
|
547
|
+
void this.#pumpStream(stream).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** @param {GatewayStream} stream - Stream. @param {number} credit - Window credit. */
|
|
551
|
+
#addWindow(stream, credit) {
|
|
552
|
+
if (!Number.isSafeInteger(credit) || credit <= 0 || credit > stream.sendWindowLimit) throw new Error("Invalid reverse gateway WINDOW")
|
|
553
|
+
stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + credit)
|
|
554
|
+
void this.#pumpStream(stream).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** @param {GatewayStream} stream - Stream. @param {string} code - Reset code. */
|
|
558
|
+
#resetStream(stream, code) {
|
|
559
|
+
if (!this.#streams.has(stream.id)) return
|
|
560
|
+
this.#logTargetTerminal(stream, code)
|
|
561
|
+
void this.#sendControl(V3FrameType.RESET, stream.id, 0, {code})
|
|
562
|
+
this.#destroyStream(stream)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** @param {GatewayStream} stream - Stream. */
|
|
566
|
+
#destroyStream(stream, code = "TARGET_CLOSED") {
|
|
567
|
+
if (!this.#streams.delete(stream.id)) return
|
|
568
|
+
this.#logTargetTerminal(stream, code)
|
|
569
|
+
if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
|
|
570
|
+
this.#bufferedBytes = Math.max(0, this.#bufferedBytes - stream.bufferedBytes)
|
|
571
|
+
stream.replay = []
|
|
572
|
+
stream.bufferedBytes = 0
|
|
573
|
+
stream.targetSocket.destroy()
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** @param {GatewayStream} stream - Stream. @param {string} code - Terminal classification. */
|
|
577
|
+
#logTargetTerminal(stream, code) {
|
|
578
|
+
if (stream.targetTerminalLogged) return
|
|
579
|
+
stream.targetTerminalLogged = true
|
|
580
|
+
logDiagnostic(this.#options.logger, stream.targetOpened ? "reverse_gateway.target_closed" : "reverse_gateway.target_open_failed", {
|
|
581
|
+
streamId: stream.id,
|
|
582
|
+
generation: this.#generation,
|
|
583
|
+
gatewayId: this.#options.gatewayId,
|
|
584
|
+
...errorDiagnostics({code}, stream.targetOpened ? "TARGET_CLOSED" : "TARGET_OPEN_FAILED")
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
|
|
589
|
+
async #sendControl(type, streamId, sequence, body) {
|
|
590
|
+
const physical = this.#physical
|
|
591
|
+
if (physical === undefined || physical.destroyed) return false
|
|
592
|
+
return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
#scheduleReconnect(/** @type {unknown} */ error = undefined) {
|
|
596
|
+
if (this.#closing || this.#fatal || this.#reconnectTimer !== undefined) return
|
|
597
|
+
this.#reconnectAttempts += 1
|
|
598
|
+
this.#setState("reconnecting")
|
|
599
|
+
const delay = this.#reconnectDelay
|
|
600
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_scheduled", {
|
|
601
|
+
generation: this.#generation,
|
|
602
|
+
attempt: this.#reconnectAttempts,
|
|
603
|
+
delayMs: delay,
|
|
604
|
+
gatewayId: this.#options.gatewayId,
|
|
605
|
+
...errorDiagnostics(error, "TRANSPORT_CLOSED")
|
|
606
|
+
})
|
|
607
|
+
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
|
|
608
|
+
this.#reconnectTimer = setTimeout(() => { this.#reconnectTimer = undefined; this.#connect() }, delay)
|
|
609
|
+
if (!this.#referenced) this.#reconnectTimer.unref?.()
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
#settleStart() { this.#resolveStart = undefined; this.#rejectStart = undefined }
|
|
613
|
+
#setState(/** @type {'configured' | 'connecting' | 'registering' | 'ready' | 'reconnecting' | 'closed'} */ state) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state) }
|
|
614
|
+
#initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
|
|
615
|
+
#maxFrameBytes() { return Math.min(this.#options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, getMaxDecompressedBytes(this.#options.compressionLimits), 64 * 1024) }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** @param {OutboundReverseGatewayOptions} options - Options. */
|
|
619
|
+
function validateOptions(options) {
|
|
620
|
+
if (typeof options !== "object" || options === null || typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId)) throw new TypeError("Outbound reverse gateway requires a valid gatewayId")
|
|
621
|
+
if (typeof options.connectionId !== "string" || !STABLE_IDENTIFIER.test(options.connectionId)) throw new TypeError("Outbound reverse gateway requires a stable connectionId")
|
|
622
|
+
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("Outbound reverse gateway requires hub host, port and token")
|
|
623
|
+
if (typeof options.authorizeTarget !== "function") throw new TypeError("Outbound reverse gateway requires a local target authorization policy")
|
|
624
|
+
resolveVerifiedTls(options.tls)
|
|
625
|
+
if (!zstdAvailable()) throw new Error("Reverse gateway mode requires Node.js zstd support")
|
|
626
|
+
getMaxDecompressedBytes(options.compressionLimits)
|
|
627
|
+
const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
|
|
628
|
+
const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
|
|
629
|
+
if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
|
|
630
|
+
positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
|
|
631
|
+
positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
|
|
632
|
+
positiveInteger(options.maxTotalBufferBytes, "maxTotalBufferBytes", DEFAULT_TOTAL_BUFFER_BYTES)
|
|
633
|
+
if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** @param {import('node:net').Socket} socket - Local target. @param {Buffer} data - Bytes. */
|
|
637
|
+
async function writeTarget(socket, data) {
|
|
638
|
+
if (socket.destroyed || !socket.writable) throw new Error("Local target is unavailable")
|
|
639
|
+
await new Promise((resolve, reject) => socket.write(data, (error) => error ? reject(error) : resolve(undefined)))
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function fatalError(/** @type {string} */ code) {
|
|
643
|
+
const error = /** @type {Error & {code: string, fatal: boolean}} */ (Object.assign(new Error("Reverse gateway was rejected by the hub"), {code, fatal: true}))
|
|
644
|
+
return error
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function connectionError(/** @type {string} */ code) {
|
|
648
|
+
if (code === "HUB_SHUTDOWN") return Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false})
|
|
649
|
+
return fatalError(code)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
|
|
653
|
+
function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0 }
|
|
654
|
+
|
|
655
|
+
function closedError() { return Object.assign(new Error("Outbound reverse gateway is closed"), {code: "SOCKETDUCT_GATEWAY_CLOSED"}) }
|
|
656
|
+
|
|
657
|
+
function isFatalTransportError(/** @type {Error} */ error) {
|
|
658
|
+
const code = /** @type {{code?: unknown}} */ (error).code
|
|
659
|
+
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"))
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** @param {OutboundReverseGatewayOptions} options - Options. */
|
|
663
|
+
export function createOutboundReverseGateway(options) { return new OutboundReverseGateway(options) }
|