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,798 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
|
|
2
|
+
import {readFileSync} from "node:fs"
|
|
3
|
+
import {createConnection, createServer} from "node:net"
|
|
4
|
+
import {createServer as createTlsServer} from "node:tls"
|
|
5
|
+
|
|
6
|
+
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_WINDOW = 256 * 1024
|
|
9
|
+
const DEFAULT_FRAME_BYTES = 16 * 1024
|
|
10
|
+
const DEFAULT_STREAM_BYTES = 4 * 1024 * 1024
|
|
11
|
+
const DEFAULT_CONNECTION_BYTES = 32 * 1024 * 1024
|
|
12
|
+
const DEFAULT_RESUME_MS = 60_000
|
|
13
|
+
const DEFAULT_HANDSHAKE_MS = 10_000
|
|
14
|
+
const DEFAULT_MAX_INPUT_BYTES = 64 * 1024
|
|
15
|
+
const DEFAULT_TARGET_CONNECT_TIMEOUT_MS = 10_000
|
|
16
|
+
const MANIFEST_PAGE_STREAMS = 64
|
|
17
|
+
const MAX_CONTROL_QUEUE_BYTES = 1024 * 1024
|
|
18
|
+
const STABLE_IDENTIFIER = /^[A-Za-z0-9_-]{8,128}$/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{userId: string, username: string}} Principal
|
|
22
|
+
* @typedef {{host: string, port: number}} Target
|
|
23
|
+
* @typedef {object} MultiplexedRelayOptions
|
|
24
|
+
* @property {string} [host] - Listen host, default loopback.
|
|
25
|
+
* @property {number} [port] - Listen port.
|
|
26
|
+
* @property {{authenticate: (token: string) => Principal | undefined}} tokens - Bearer authenticator.
|
|
27
|
+
* @property {(input: {principal: Principal, target: Target}) => boolean} authorizeTarget - Exact per-open target policy.
|
|
28
|
+
* @property {(target: Target) => import('node:net').Socket} [createTargetConnection] - Target socket factory.
|
|
29
|
+
* @property {{certFile: string, keyFile: string, clientCaFile?: string}} [tls] - Outer TLS server settings.
|
|
30
|
+
* @property {number} [initialWindowBytes] - Per-stream flow-control window.
|
|
31
|
+
* @property {number} [maxFrameBytes] - Maximum DATA payload.
|
|
32
|
+
* @property {number} [maxStreamBufferBytes] - Per-stream retained output bound.
|
|
33
|
+
* @property {number} [maxConnectionBufferBytes] - Aggregate retained output bound.
|
|
34
|
+
* @property {number} [maxTotalBufferBytes] - Process-wide retained output bound across connection identities.
|
|
35
|
+
* @property {number} [maxStreams] - Streams per physical connection identity.
|
|
36
|
+
* @property {number} [resumeWindowMs] - Detached target retention.
|
|
37
|
+
* @property {number} [handshakeTimeoutMs] - Authentication deadline.
|
|
38
|
+
* @property {number} [maxInputBytesPerConnection] - Cumulative raw pre-authentication input bound.
|
|
39
|
+
* @property {number} [targetConnectTimeoutMs] - Per-stream target-open deadline.
|
|
40
|
+
* @typedef {object} MultiplexedRelayStatus
|
|
41
|
+
* @property {number} physicalAccepts - Lifetime accepted WAN transports.
|
|
42
|
+
* @property {number} physicalConnections - Current physical WAN transports.
|
|
43
|
+
* @property {number} authenticatedConnections - Current authenticated transports.
|
|
44
|
+
* @property {number} connectionIdentities - Retained resumable connections.
|
|
45
|
+
* @property {number} connectionGeneration - Highest current generation.
|
|
46
|
+
* @property {number} virtualStreams - Current virtual streams.
|
|
47
|
+
* @property {number} activeTargets - Current local target sockets.
|
|
48
|
+
* @property {number} queuedBytes - Bounded retained target bytes.
|
|
49
|
+
* @property {number} bufferedBytes - Process-wide retained replay payload bytes.
|
|
50
|
+
* @property {number} bufferLimitBytes - Process-wide replay payload limit.
|
|
51
|
+
* @property {number} openFailures - Target-open failures.
|
|
52
|
+
* @property {number} resets - Total stream resets.
|
|
53
|
+
* @property {number} resumeSuccesses - Resumed streams.
|
|
54
|
+
* @property {number} resumeFailures - Stream-local resume failures.
|
|
55
|
+
* @property {number} streamOpenLatencyMs - Average target-open latency.
|
|
56
|
+
* @property {Record<string, number>} resetReasons - Stable reason counters.
|
|
57
|
+
* @typedef {{sequence: number, data: Buffer}} RelayChunk
|
|
58
|
+
* @typedef {{wire: Buffer, type: number, payloadBytes: number}} RelayQueuedFrame
|
|
59
|
+
* @typedef {object} RelayStream
|
|
60
|
+
* @property {number} id - Wire stream ID.
|
|
61
|
+
* @property {string} identifier - Stable stream identifier.
|
|
62
|
+
* @property {string} name - Human name.
|
|
63
|
+
* @property {Target} target - Authorized target.
|
|
64
|
+
* @property {import('node:net').Socket} targetSocket - Local target connection.
|
|
65
|
+
* @property {boolean} opened - Target ready.
|
|
66
|
+
* @property {boolean} localEnded - Client sent EOF.
|
|
67
|
+
* @property {boolean} remoteEnded - Target sent EOF.
|
|
68
|
+
* @property {boolean} lost - Target was lost while detached.
|
|
69
|
+
* @property {number} openStartedAt - Monotonic-enough target open start timestamp.
|
|
70
|
+
* @property {ReturnType<typeof setTimeout> | undefined} openTimer - Target-open deadline.
|
|
71
|
+
* @property {number} receiveSequence - Client bytes committed to target.
|
|
72
|
+
* @property {number} pendingReceiveSequence - Client bytes accepted for ordered target writes.
|
|
73
|
+
* @property {number} sendSequence - Target bytes allocated.
|
|
74
|
+
* @property {number} sendAck - Client target-data acknowledgement.
|
|
75
|
+
* @property {number} sendWindow - Available client receive window.
|
|
76
|
+
* @property {number} bufferedBytes - Retained target bytes.
|
|
77
|
+
* @property {RelayChunk[]} replay - Unacknowledged target bytes.
|
|
78
|
+
* @property {RelayQueuedFrame[]} queue - Fair scheduler queue.
|
|
79
|
+
* @typedef {object} RelayConnection
|
|
80
|
+
* @property {string} id - Stable client physical identity.
|
|
81
|
+
* @property {Principal} principal - Authenticated owner.
|
|
82
|
+
* @property {number} generation - Current generation.
|
|
83
|
+
* @property {import('node:net').Socket | undefined} physical - Current WAN transport.
|
|
84
|
+
* @property {Map<number, RelayStream>} streams - Virtual streams.
|
|
85
|
+
* @property {Set<number>} seenStreamIds - IDs that cannot be reused.
|
|
86
|
+
* @property {Buffer[]} controlQueue - Priority control frames.
|
|
87
|
+
* @property {number} controlQueuedBytes - Bounded priority-control bytes.
|
|
88
|
+
* @property {boolean} pumping - Scheduler state.
|
|
89
|
+
* @property {number} cursor - Scheduler cursor.
|
|
90
|
+
* @property {number} bufferedBytes - Aggregate replay bytes.
|
|
91
|
+
* @property {ReturnType<typeof setTimeout> | undefined} expiryTimer - Detached expiry.
|
|
92
|
+
* @property {number} resumeNextPage - Expected resume manifest page.
|
|
93
|
+
* @property {Array<Record<string, unknown>>} resumeItems - Accumulated bounded manifest.
|
|
94
|
+
* @property {boolean} resumeRequired - Whether OPEN/data must wait for a complete manifest.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Starts a protocol-v3 relay endpoint. Protocol-v2 remains available through startTcpRelay.
|
|
99
|
+
* @param {MultiplexedRelayOptions} options - Auth, authorization, TLS and bounds.
|
|
100
|
+
* @returns {Promise<{server: import('node:net').Server | import('node:tls').Server, port: number, status: () => MultiplexedRelayStatus, closeActiveTransport: () => Promise<void>, close: () => Promise<void>}>} Relay handle.
|
|
101
|
+
*/
|
|
102
|
+
export async function startMultiplexedTcpRelay(options) {
|
|
103
|
+
validateOptions(options)
|
|
104
|
+
const initialWindow = options.initialWindowBytes ?? DEFAULT_WINDOW
|
|
105
|
+
const maxFrameBytes = Math.min(options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, 64 * 1024)
|
|
106
|
+
const maxStreamBytes = options.maxStreamBufferBytes ?? DEFAULT_STREAM_BYTES
|
|
107
|
+
const maxConnectionBytes = options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BYTES
|
|
108
|
+
const maxTotalBufferBytes = options.maxTotalBufferBytes ?? DEFAULT_CONNECTION_BYTES
|
|
109
|
+
const maxStreams = options.maxStreams ?? 4096
|
|
110
|
+
const resumeWindowMs = options.resumeWindowMs ?? DEFAULT_RESUME_MS
|
|
111
|
+
const maxInputBytesPerConnection = options.maxInputBytesPerConnection ?? DEFAULT_MAX_INPUT_BYTES
|
|
112
|
+
const createTarget = options.createTargetConnection ?? ((target) => createConnection(target))
|
|
113
|
+
/** @type {Map<string, RelayConnection>} */
|
|
114
|
+
const connections = new Map()
|
|
115
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
116
|
+
const physicals = new Set()
|
|
117
|
+
let physicalAccepts = 0
|
|
118
|
+
let authenticatedConnections = 0
|
|
119
|
+
let activeTargets = 0
|
|
120
|
+
let openFailures = 0
|
|
121
|
+
let resets = 0
|
|
122
|
+
let resumeSuccesses = 0
|
|
123
|
+
let resumeFailures = 0
|
|
124
|
+
let streamOpenLatencyTotalMs = 0
|
|
125
|
+
let streamOpenLatencyCount = 0
|
|
126
|
+
let processBufferedBytes = 0
|
|
127
|
+
/** @type {Map<string, number>} */
|
|
128
|
+
const resetReasons = new Map()
|
|
129
|
+
let closing = false
|
|
130
|
+
|
|
131
|
+
const server = options.tls === undefined
|
|
132
|
+
? createServer(acceptPhysical)
|
|
133
|
+
: createTlsServer({
|
|
134
|
+
cert: readFileSync(options.tls.certFile),
|
|
135
|
+
key: readFileSync(options.tls.keyFile),
|
|
136
|
+
...(options.tls.clientCaFile === undefined ? {} : {ca: readFileSync(options.tls.clientCaFile)}),
|
|
137
|
+
requestCert: options.tls.clientCaFile !== undefined,
|
|
138
|
+
rejectUnauthorized: options.tls.clientCaFile !== undefined
|
|
139
|
+
}, acceptPhysical)
|
|
140
|
+
|
|
141
|
+
/** @param {import('node:net').Socket} socket - Accepted physical connection. @returns {void} */
|
|
142
|
+
function acceptPhysical(socket) {
|
|
143
|
+
physicalAccepts += 1
|
|
144
|
+
physicals.add(socket)
|
|
145
|
+
const decoderLimit = Math.max(64 * 1024, maxFrameBytes)
|
|
146
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: decoderLimit, maxBufferedBytes: decoderLimit * 2})
|
|
147
|
+
/** @type {RelayConnection | undefined} */
|
|
148
|
+
let connection
|
|
149
|
+
let authenticated = false
|
|
150
|
+
let unauthenticatedInputBytes = 0
|
|
151
|
+
let queue = Promise.resolve()
|
|
152
|
+
const handshakeTimer = setTimeout(() => {
|
|
153
|
+
if (!authenticated) sendImmediate(socket, V3FrameType.AUTH_ERROR, 0, 0, {code: "HANDSHAKE_TIMEOUT"}).finally(() => socket.end())
|
|
154
|
+
}, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_MS)
|
|
155
|
+
handshakeTimer.unref?.()
|
|
156
|
+
|
|
157
|
+
socket.on("data", (chunk) => {
|
|
158
|
+
const inputLimitExceeded = !authenticated && (unauthenticatedInputBytes += chunk.length) > maxInputBytesPerConnection
|
|
159
|
+
queue = queue.then(async () => {
|
|
160
|
+
if (inputLimitExceeded) throw protocolError("INPUT_LIMIT_EXCEEDED")
|
|
161
|
+
const frames = decoder.push(chunk)
|
|
162
|
+
for (const frame of frames) {
|
|
163
|
+
if (!authenticated) {
|
|
164
|
+
if (frame.type !== V3FrameType.AUTH || frame.streamId !== 0) throw protocolError("AUTH_REQUIRED")
|
|
165
|
+
const auth = decodeV3Control(frame.payload)
|
|
166
|
+
if (!Array.isArray(auth.versions) || !auth.versions.includes(3) || typeof auth.connectionId !== "string" || !STABLE_IDENTIFIER.test(auth.connectionId) || typeof auth.generation !== "number" || !Number.isSafeInteger(auth.generation) || auth.generation <= 0) {
|
|
167
|
+
await sendImmediate(socket, V3FrameType.AUTH_ERROR, 0, 0, {code: "PROTOCOL_NEGOTIATION_FAILED"})
|
|
168
|
+
socket.end()
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const principal = typeof auth.token === "string" ? options.tokens.authenticate(auth.token) : undefined
|
|
172
|
+
if (principal === undefined) {
|
|
173
|
+
await sendImmediate(socket, V3FrameType.AUTH_ERROR, 0, 0, {code: "AUTH_REQUIRED"})
|
|
174
|
+
socket.end()
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
const existing = connections.get(auth.connectionId)
|
|
178
|
+
if (existing !== undefined && existing.principal.userId !== principal.userId) {
|
|
179
|
+
await sendImmediate(socket, V3FrameType.AUTH_ERROR, 0, 0, {code: "AUTH_OWNERSHIP_MISMATCH"})
|
|
180
|
+
socket.end()
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (existing !== undefined && auth.generation <= existing.generation) {
|
|
184
|
+
await sendImmediate(socket, V3FrameType.AUTH_ERROR, 0, 0, {code: "STALE_GENERATION"})
|
|
185
|
+
socket.end()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
connection = existing ?? {
|
|
189
|
+
id: auth.connectionId,
|
|
190
|
+
principal,
|
|
191
|
+
generation: 0,
|
|
192
|
+
physical: undefined,
|
|
193
|
+
streams: new Map(),
|
|
194
|
+
seenStreamIds: new Set(),
|
|
195
|
+
controlQueue: [],
|
|
196
|
+
controlQueuedBytes: 0,
|
|
197
|
+
pumping: false,
|
|
198
|
+
cursor: 0,
|
|
199
|
+
bufferedBytes: 0,
|
|
200
|
+
expiryTimer: undefined,
|
|
201
|
+
resumeNextPage: 0,
|
|
202
|
+
resumeItems: [],
|
|
203
|
+
resumeRequired: false
|
|
204
|
+
}
|
|
205
|
+
if (connection.expiryTimer !== undefined) clearTimeout(connection.expiryTimer)
|
|
206
|
+
connection.expiryTimer = undefined
|
|
207
|
+
connection.controlQueue = []
|
|
208
|
+
connection.controlQueuedBytes = 0
|
|
209
|
+
connection.cursor = 0
|
|
210
|
+
for (const stream of connection.streams.values()) stream.queue = []
|
|
211
|
+
connection.physical?.destroy()
|
|
212
|
+
connection.physical = socket
|
|
213
|
+
connection.generation = auth.generation
|
|
214
|
+
connection.resumeNextPage = 0
|
|
215
|
+
connection.resumeItems = []
|
|
216
|
+
connection.resumeRequired = existing !== undefined
|
|
217
|
+
connections.set(connection.id, connection)
|
|
218
|
+
authenticated = true
|
|
219
|
+
authenticatedConnections += 1
|
|
220
|
+
clearTimeout(handshakeTimer)
|
|
221
|
+
await sendImmediate(socket, V3FrameType.AUTH_OK, 0, connection.generation, {
|
|
222
|
+
version: 3,
|
|
223
|
+
connectionId: connection.id,
|
|
224
|
+
generation: connection.generation,
|
|
225
|
+
resumed: existing !== undefined,
|
|
226
|
+
initialWindow,
|
|
227
|
+
compression: null
|
|
228
|
+
})
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if (connection === undefined || connection.physical !== socket) continue
|
|
232
|
+
await handleFrame(connection, frame)
|
|
233
|
+
}
|
|
234
|
+
}).catch(async (error) => {
|
|
235
|
+
const code = /** @type {{code?: unknown}} */ (error).code
|
|
236
|
+
await sendImmediate(socket, V3FrameType.GOAWAY, 0, 0, {code: typeof code === "string" ? code : "PROTOCOL_VIOLATION"}).catch(() => {})
|
|
237
|
+
socket.destroy()
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
socket.on("error", () => {})
|
|
241
|
+
socket.once("close", () => {
|
|
242
|
+
clearTimeout(handshakeTimer)
|
|
243
|
+
physicals.delete(socket)
|
|
244
|
+
if (authenticated) authenticatedConnections = Math.max(0, authenticatedConnections - 1)
|
|
245
|
+
void queue.finally(() => {
|
|
246
|
+
if (connection === undefined || connection.physical !== socket || closing) return
|
|
247
|
+
connection.physical = undefined
|
|
248
|
+
connection.controlQueue = []
|
|
249
|
+
connection.controlQueuedBytes = 0
|
|
250
|
+
for (const stream of connection.streams.values()) stream.queue = []
|
|
251
|
+
const detachedConnection = connection
|
|
252
|
+
detachedConnection.expiryTimer = setTimeout(() => destroyConnection(detachedConnection), resumeWindowMs)
|
|
253
|
+
detachedConnection.expiryTimer.unref?.()
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {RelayConnection} connection - Authenticated connection. @param {import('../protocol/v3-frame.js').V3Frame} frame - Frame. @returns {Promise<void>}
|
|
260
|
+
* @param frame
|
|
261
|
+
*/
|
|
262
|
+
async function handleFrame(connection, frame) {
|
|
263
|
+
if (frame.type === V3FrameType.PING) {
|
|
264
|
+
queueControl(connection, V3FrameType.PONG, 0, frame.sequence, {})
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
if (frame.type === V3FrameType.PONG) return
|
|
268
|
+
if (frame.type === V3FrameType.GOAWAY) {
|
|
269
|
+
destroyConnection(connection)
|
|
270
|
+
connection.physical?.end()
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (connection.resumeRequired && frame.type !== V3FrameType.RESUME) throw protocolError("RESUME_REQUIRED")
|
|
274
|
+
if (frame.type === V3FrameType.RESUME) {
|
|
275
|
+
const manifest = decodeV3Control(frame.payload)
|
|
276
|
+
if (manifest.page !== connection.resumeNextPage || !Array.isArray(manifest.streams) || manifest.streams.length > MANIFEST_PAGE_STREAMS || connection.resumeItems.length + manifest.streams.length > maxStreams) throw protocolError("INVALID_RESUME_MANIFEST")
|
|
277
|
+
connection.resumeNextPage += 1
|
|
278
|
+
connection.resumeItems.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
|
|
279
|
+
if (manifest.final !== true) return
|
|
280
|
+
const presented = new Set()
|
|
281
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
282
|
+
const results = []
|
|
283
|
+
for (const value of connection.resumeItems) {
|
|
284
|
+
if (typeof value !== "object" || value === null) continue
|
|
285
|
+
const item = /** @type {{streamId?: unknown, identifier?: unknown, clientToTargetAck?: unknown, targetToClientAck?: unknown, localEnded?: unknown}} */ (value)
|
|
286
|
+
if (typeof item.streamId !== "number" || typeof item.identifier !== "string") continue
|
|
287
|
+
if (presented.has(item.streamId)) throw protocolError("DUPLICATE_RESUME_STREAM")
|
|
288
|
+
presented.add(item.streamId)
|
|
289
|
+
const stream = connection.streams.get(item.streamId)
|
|
290
|
+
if (stream === undefined || stream.identifier !== item.identifier || stream.lost || stream.targetSocket.destroyed) {
|
|
291
|
+
results.push({streamId: item.streamId, status: "reset", code: "RESUME_SESSION_LOST"})
|
|
292
|
+
resumeFailures += 1
|
|
293
|
+
if (stream !== undefined) destroyStream(connection, stream)
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
if (typeof item.targetToClientAck === "number") acknowledgeTarget(connection, stream, item.targetToClientAck)
|
|
297
|
+
results.push({
|
|
298
|
+
streamId: stream.id,
|
|
299
|
+
status: "resumed",
|
|
300
|
+
clientToTargetAck: stream.receiveSequence,
|
|
301
|
+
targetToClientAck: stream.sendAck,
|
|
302
|
+
opened: stream.opened
|
|
303
|
+
})
|
|
304
|
+
if (item.localEnded === true && !stream.localEnded) {
|
|
305
|
+
stream.localEnded = true
|
|
306
|
+
stream.targetSocket.end()
|
|
307
|
+
}
|
|
308
|
+
resumeSuccesses += 1
|
|
309
|
+
}
|
|
310
|
+
for (const stream of [...connection.streams.values()]) {
|
|
311
|
+
if (!presented.has(stream.id)) destroyStream(connection, stream)
|
|
312
|
+
}
|
|
313
|
+
const pageCount = Math.max(1, Math.ceil(results.length / MANIFEST_PAGE_STREAMS))
|
|
314
|
+
for (let page = 0; page < pageCount; page += 1) {
|
|
315
|
+
queueControl(connection, V3FrameType.RESUMED, 0, connection.generation, {
|
|
316
|
+
page,
|
|
317
|
+
final: page === pageCount - 1,
|
|
318
|
+
streams: results.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
connection.resumeItems = []
|
|
322
|
+
connection.resumeRequired = false
|
|
323
|
+
connection.cursor = 0
|
|
324
|
+
for (const stream of connection.streams.values()) {
|
|
325
|
+
stream.queue = stream.queue.filter((entry) => entry.type !== V3FrameType.DATA)
|
|
326
|
+
// Credit belongs to the replaced physical generation. The resume ACKs
|
|
327
|
+
// above establish the durable replay boundary for a fresh full window.
|
|
328
|
+
stream.sendWindow = initialWindow
|
|
329
|
+
for (const chunk of stream.replay) if (chunk.sequence > stream.sendAck) queueData(connection, stream, chunk)
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if (frame.type === V3FrameType.OPEN) {
|
|
334
|
+
if (connection.seenStreamIds.has(frame.streamId) || connection.streams.size >= maxStreams || frame.streamId % 2 === 0) throw protocolError("INVALID_STREAM_ID")
|
|
335
|
+
connection.seenStreamIds.add(frame.streamId)
|
|
336
|
+
const request = decodeV3Control(frame.payload)
|
|
337
|
+
const target = parseTarget(request.target)
|
|
338
|
+
if (target === undefined || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || typeof request.initialWindow !== "number" || !Number.isSafeInteger(request.initialWindow) || request.initialWindow <= 0) {
|
|
339
|
+
queueControl(connection, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
if (options.authorizeTarget({principal: connection.principal, target}) !== true) {
|
|
343
|
+
queueControl(connection, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
|
|
344
|
+
recordReset("TARGET_UNAUTHORIZED")
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
const targetSocket = createTarget(target)
|
|
348
|
+
const stream = /** @type {RelayStream} */ ({
|
|
349
|
+
id: frame.streamId,
|
|
350
|
+
identifier: request.identifier,
|
|
351
|
+
name: request.name,
|
|
352
|
+
target,
|
|
353
|
+
targetSocket,
|
|
354
|
+
opened: false,
|
|
355
|
+
localEnded: false,
|
|
356
|
+
remoteEnded: false,
|
|
357
|
+
lost: false,
|
|
358
|
+
openStartedAt: Date.now(),
|
|
359
|
+
openTimer: undefined,
|
|
360
|
+
receiveSequence: 0,
|
|
361
|
+
pendingReceiveSequence: 0,
|
|
362
|
+
sendSequence: 0,
|
|
363
|
+
sendAck: 0,
|
|
364
|
+
sendWindow: typeof request.initialWindow === "number" ? Math.min(request.initialWindow, initialWindow) : initialWindow,
|
|
365
|
+
bufferedBytes: 0,
|
|
366
|
+
replay: [],
|
|
367
|
+
queue: []
|
|
368
|
+
})
|
|
369
|
+
connection.streams.set(stream.id, stream)
|
|
370
|
+
attachTarget(connection, stream)
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
const stream = connection.streams.get(frame.streamId)
|
|
374
|
+
if (stream === undefined) {
|
|
375
|
+
if (frame.type !== V3FrameType.RESET && frame.type !== V3FrameType.CLOSE) queueControl(connection, V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
if (!stream.opened && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_NOT_OPEN")
|
|
379
|
+
if (frame.type === V3FrameType.DATA) {
|
|
380
|
+
if (frame.flags !== 0) throw protocolError("COMPRESSION_NOT_NEGOTIATED")
|
|
381
|
+
if (frame.payload.length > maxFrameBytes) throw protocolError("FRAME_TOO_LARGE")
|
|
382
|
+
if (frame.sequence <= stream.receiveSequence) {
|
|
383
|
+
queueControl(connection, V3FrameType.ACK, stream.id, stream.receiveSequence, {})
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
if (frame.sequence <= stream.pendingReceiveSequence) return
|
|
387
|
+
if (frame.sequence !== stream.pendingReceiveSequence + frame.payload.length) {
|
|
388
|
+
resetStream(connection, stream, "SEQUENCE_GAP")
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
const committedSequence = frame.sequence
|
|
392
|
+
const receivedGeneration = connection.generation
|
|
393
|
+
stream.pendingReceiveSequence = committedSequence
|
|
394
|
+
if (stream.pendingReceiveSequence - stream.receiveSequence > initialWindow) {
|
|
395
|
+
resetStream(connection, stream, "FLOW_CONTROL_ERROR")
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
stream.targetSocket.write(frame.payload, (error) => {
|
|
399
|
+
if (error || stream.targetSocket.destroyed) {
|
|
400
|
+
resetStream(connection, stream, "TARGET_WRITE_UNCERTAIN")
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
stream.receiveSequence = Math.max(stream.receiveSequence, committedSequence)
|
|
404
|
+
if (connection.generation === receivedGeneration && connection.physical !== undefined && !connection.resumeRequired) queueControl(connection, V3FrameType.ACK, stream.id, committedSequence, {})
|
|
405
|
+
})
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
if (frame.type === V3FrameType.ACK) {
|
|
409
|
+
acknowledgeTarget(connection, stream, frame.sequence)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
if (frame.type === V3FrameType.WINDOW) {
|
|
413
|
+
if (frame.sequence > initialWindow) throw protocolError("INVALID_WINDOW")
|
|
414
|
+
stream.sendWindow = Math.min(initialWindow, stream.sendWindow + frame.sequence)
|
|
415
|
+
schedulePump(connection)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
if (frame.type === V3FrameType.END) {
|
|
419
|
+
if (!stream.localEnded) {
|
|
420
|
+
stream.localEnded = true
|
|
421
|
+
stream.targetSocket.end()
|
|
422
|
+
}
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
if (frame.type === V3FrameType.RESET || frame.type === V3FrameType.CLOSE) {
|
|
426
|
+
destroyStream(connection, stream)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
throw protocolError("UNSUPPORTED_FRAME")
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @param {RelayConnection} connection - Owner. @param {RelayStream} stream - Stream. @returns {void}
|
|
434
|
+
* @param stream
|
|
435
|
+
*/
|
|
436
|
+
function attachTarget(connection, stream) {
|
|
437
|
+
let connected = false
|
|
438
|
+
stream.openTimer = setTimeout(() => {
|
|
439
|
+
if (connected || !connection.streams.has(stream.id)) return
|
|
440
|
+
openFailures += 1
|
|
441
|
+
resetStream(connection, stream, "TARGET_OPEN_TIMEOUT")
|
|
442
|
+
}, options.targetConnectTimeoutMs ?? DEFAULT_TARGET_CONNECT_TIMEOUT_MS)
|
|
443
|
+
stream.openTimer.unref?.()
|
|
444
|
+
stream.targetSocket.once("connect", () => {
|
|
445
|
+
connected = true
|
|
446
|
+
if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
|
|
447
|
+
stream.openTimer = undefined
|
|
448
|
+
stream.opened = true
|
|
449
|
+
activeTargets += 1
|
|
450
|
+
streamOpenLatencyTotalMs += Math.max(0, Date.now() - stream.openStartedAt)
|
|
451
|
+
streamOpenLatencyCount += 1
|
|
452
|
+
queueControl(connection, V3FrameType.OPENED, stream.id, 0, {identifier: stream.identifier, initialWindow})
|
|
453
|
+
})
|
|
454
|
+
stream.targetSocket.on("data", (chunk) => {
|
|
455
|
+
if (!stream.opened || stream.lost) return
|
|
456
|
+
if (processBufferedBytes + chunk.length > maxTotalBufferBytes) {
|
|
457
|
+
resetStream(connection, stream, "PROCESS_BUFFER_LIMIT")
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (stream.bufferedBytes + chunk.length > maxStreamBytes || connection.bufferedBytes + chunk.length > maxConnectionBytes) {
|
|
461
|
+
resetStream(connection, stream, "BUFFER_LIMIT")
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
for (let offset = 0; offset < chunk.length; offset += maxFrameBytes) {
|
|
465
|
+
const data = Buffer.from(chunk.subarray(offset, Math.min(chunk.length, offset + maxFrameBytes)))
|
|
466
|
+
stream.sendSequence += data.length
|
|
467
|
+
const replay = {sequence: stream.sendSequence, data}
|
|
468
|
+
stream.replay.push(replay)
|
|
469
|
+
stream.bufferedBytes += data.length
|
|
470
|
+
connection.bufferedBytes += data.length
|
|
471
|
+
processBufferedBytes += data.length
|
|
472
|
+
queueData(connection, stream, replay)
|
|
473
|
+
}
|
|
474
|
+
if (stream.bufferedBytes >= maxStreamBytes || connection.bufferedBytes >= maxConnectionBytes || processBufferedBytes >= maxTotalBufferBytes || stream.sendWindow === 0) stream.targetSocket.pause()
|
|
475
|
+
})
|
|
476
|
+
stream.targetSocket.once("end", () => {
|
|
477
|
+
stream.remoteEnded = true
|
|
478
|
+
if (connection.physical !== undefined) queueStream(connection, stream, V3FrameType.END, stream.sendSequence)
|
|
479
|
+
})
|
|
480
|
+
stream.targetSocket.once("error", () => {
|
|
481
|
+
if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
|
|
482
|
+
stream.openTimer = undefined
|
|
483
|
+
if (!connected) {
|
|
484
|
+
if (connection.streams.has(stream.id)) {
|
|
485
|
+
openFailures += 1
|
|
486
|
+
resetStream(connection, stream, "TARGET_OPEN_FAILED")
|
|
487
|
+
}
|
|
488
|
+
} else if (connection.physical === undefined) {
|
|
489
|
+
stream.lost = true
|
|
490
|
+
} else {
|
|
491
|
+
resetStream(connection, stream, "TARGET_CLOSED")
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
stream.targetSocket.once("close", () => {
|
|
495
|
+
if (connected) activeTargets = Math.max(0, activeTargets - 1)
|
|
496
|
+
if (!connection.streams.has(stream.id)) return
|
|
497
|
+
if (connection.physical === undefined) {
|
|
498
|
+
stream.lost = true
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
stream.remoteEnded = true
|
|
502
|
+
queueStream(connection, stream, V3FrameType.CLOSE, stream.sendSequence)
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @param {RelayConnection} connection - Owner. @param {RelayStream} stream - Stream. @param {RelayChunk} chunk - Chunk. @returns {void}
|
|
508
|
+
* @param stream
|
|
509
|
+
* @param chunk
|
|
510
|
+
*/
|
|
511
|
+
function queueData(connection, stream, chunk) {
|
|
512
|
+
stream.queue.push({
|
|
513
|
+
wire: encodeV3Frame({type: V3FrameType.DATA, streamId: stream.id, sequence: chunk.sequence}, chunk.data),
|
|
514
|
+
type: V3FrameType.DATA,
|
|
515
|
+
payloadBytes: chunk.data.length
|
|
516
|
+
})
|
|
517
|
+
schedulePump(connection)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {RelayConnection} connection - Owner. @param {RelayStream} stream - Stream. @param {number} type - Type. @param {number} sequence - Sequence. @returns {void}
|
|
522
|
+
* @param stream
|
|
523
|
+
* @param type
|
|
524
|
+
* @param sequence
|
|
525
|
+
*/
|
|
526
|
+
function queueStream(connection, stream, type, sequence) {
|
|
527
|
+
stream.queue.push({wire: encodeV3Frame({type, streamId: stream.id, sequence}), type, payloadBytes: 0})
|
|
528
|
+
schedulePump(connection)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* @param {RelayConnection} connection - Owner. @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} detail - Body. @returns {void}
|
|
533
|
+
* @param type
|
|
534
|
+
* @param streamId
|
|
535
|
+
* @param sequence
|
|
536
|
+
* @param detail
|
|
537
|
+
*/
|
|
538
|
+
function queueControl(connection, type, streamId, sequence, detail) {
|
|
539
|
+
const wire = encodeV3Frame({type, streamId, sequence}, encodeV3Control(detail))
|
|
540
|
+
if (connection.controlQueuedBytes + wire.length > MAX_CONTROL_QUEUE_BYTES) {
|
|
541
|
+
destroyConnection(connection)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
connection.controlQueue.push(wire)
|
|
545
|
+
connection.controlQueuedBytes += wire.length
|
|
546
|
+
schedulePump(connection)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** @param {RelayConnection} connection - Owner. @returns {void} */
|
|
550
|
+
function schedulePump(connection) {
|
|
551
|
+
if (connection.pumping || connection.physical === undefined || connection.physical.destroyed) return
|
|
552
|
+
connection.pumping = true
|
|
553
|
+
setImmediate(() => void pump(connection))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** @param {RelayConnection} connection - Owner. @returns {Promise<void>} */
|
|
557
|
+
async function pump(connection) {
|
|
558
|
+
const ownerPhysical = connection.physical
|
|
559
|
+
try {
|
|
560
|
+
while (ownerPhysical !== undefined && connection.physical === ownerPhysical && !ownerPhysical.destroyed) {
|
|
561
|
+
const physical = ownerPhysical
|
|
562
|
+
const control = connection.controlQueue.shift()
|
|
563
|
+
if (control !== undefined) {
|
|
564
|
+
connection.controlQueuedBytes = Math.max(0, connection.controlQueuedBytes - control.length)
|
|
565
|
+
if (!await writePhysical(physical, control)) break
|
|
566
|
+
continue
|
|
567
|
+
}
|
|
568
|
+
if (connection.resumeRequired) break
|
|
569
|
+
const streams = [...connection.streams.values()]
|
|
570
|
+
let selected
|
|
571
|
+
for (let index = 0; index < streams.length; index += 1) {
|
|
572
|
+
const cursor = (connection.cursor + index) % streams.length
|
|
573
|
+
const stream = /** @type {RelayStream} */ (streams[cursor])
|
|
574
|
+
const frame = stream.queue[0]
|
|
575
|
+
if (frame !== undefined && (frame.type !== V3FrameType.DATA || frame.payloadBytes <= stream.sendWindow)) {
|
|
576
|
+
selected = {stream, frame, next: (cursor + 1) % streams.length}
|
|
577
|
+
break
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (selected === undefined) break
|
|
581
|
+
selected.stream.queue.shift()
|
|
582
|
+
connection.cursor = selected.next
|
|
583
|
+
if (selected.frame.type === V3FrameType.DATA) selected.stream.sendWindow -= selected.frame.payloadBytes
|
|
584
|
+
if (!await writePhysical(physical, selected.frame.wire)) break
|
|
585
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
586
|
+
}
|
|
587
|
+
} finally {
|
|
588
|
+
connection.pumping = false
|
|
589
|
+
if (connection.controlQueue.length > 0 || (!connection.resumeRequired && [...connection.streams.values()].some((stream) => {
|
|
590
|
+
const frame = stream.queue[0]
|
|
591
|
+
return frame !== undefined && (frame.type !== V3FrameType.DATA || frame.payloadBytes <= stream.sendWindow)
|
|
592
|
+
}))) schedulePump(connection)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* @param {RelayConnection} connection - Owner. @param {RelayStream} stream - Stream. @param {number} sequence - Ack. @returns {void}
|
|
598
|
+
* @param stream
|
|
599
|
+
* @param sequence
|
|
600
|
+
*/
|
|
601
|
+
function acknowledgeTarget(connection, stream, sequence) {
|
|
602
|
+
if (!Number.isSafeInteger(sequence) || sequence < stream.sendAck || sequence > stream.sendSequence) throw protocolError("INVALID_ACK")
|
|
603
|
+
if (sequence === stream.sendAck) return
|
|
604
|
+
const oldAck = stream.sendAck
|
|
605
|
+
stream.sendAck = sequence
|
|
606
|
+
let removed = 0
|
|
607
|
+
stream.replay = stream.replay.filter((chunk) => {
|
|
608
|
+
if (chunk.sequence <= sequence) {
|
|
609
|
+
removed += chunk.data.length
|
|
610
|
+
return false
|
|
611
|
+
}
|
|
612
|
+
return true
|
|
613
|
+
})
|
|
614
|
+
releaseBuffered(connection, stream, removed)
|
|
615
|
+
stream.sendWindow = Math.min(initialWindow, stream.sendWindow + (sequence - oldAck))
|
|
616
|
+
resumeEligibleTargets()
|
|
617
|
+
schedulePump(connection)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Releases a retained-payload reservation exactly once at all ownership levels.
|
|
622
|
+
* @param {RelayConnection} connection - Connection owner.
|
|
623
|
+
* @param {RelayStream} stream - Stream owner.
|
|
624
|
+
* @param {number} requestedBytes - Bytes no longer retained.
|
|
625
|
+
* @returns {void}
|
|
626
|
+
*/
|
|
627
|
+
function releaseBuffered(connection, stream, requestedBytes) {
|
|
628
|
+
const released = Math.min(Math.max(0, requestedBytes), stream.bufferedBytes)
|
|
629
|
+
stream.bufferedBytes -= released
|
|
630
|
+
connection.bufferedBytes = Math.max(0, connection.bufferedBytes - released)
|
|
631
|
+
processBufferedBytes = Math.max(0, processBufferedBytes - released)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** @returns {void} Resumes targets made eligible by any process-wide release. */
|
|
635
|
+
function resumeEligibleTargets() {
|
|
636
|
+
if (processBufferedBytes >= maxTotalBufferBytes) return
|
|
637
|
+
for (const owner of connections.values()) {
|
|
638
|
+
if (owner.bufferedBytes >= maxConnectionBytes) continue
|
|
639
|
+
for (const candidate of owner.streams.values()) {
|
|
640
|
+
if (candidate.targetSocket.isPaused() && candidate.bufferedBytes < maxStreamBytes && candidate.sendWindow > 0) candidate.targetSocket.resume()
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* @param {RelayConnection} connection - Owner. @param {RelayStream} stream - Stream. @param {string} code - Safe reset code. @returns {void}
|
|
647
|
+
* @param stream
|
|
648
|
+
* @param code
|
|
649
|
+
*/
|
|
650
|
+
function resetStream(connection, stream, code) {
|
|
651
|
+
if (!connection.streams.has(stream.id)) return
|
|
652
|
+
recordReset(code)
|
|
653
|
+
queueControl(connection, V3FrameType.RESET, stream.id, 0, {code})
|
|
654
|
+
destroyStream(connection, stream, true)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* @param {RelayConnection} connection - Owner. @param {RelayStream} stream - Stream. @param {boolean} [keepQueuedReset] - Preserve connection control reset. @returns {void}
|
|
659
|
+
* @param stream
|
|
660
|
+
* @param keepQueuedReset
|
|
661
|
+
*/
|
|
662
|
+
function destroyStream(connection, stream, keepQueuedReset = false) {
|
|
663
|
+
connection.streams.delete(stream.id)
|
|
664
|
+
releaseBuffered(connection, stream, stream.bufferedBytes)
|
|
665
|
+
stream.replay = []
|
|
666
|
+
stream.queue = []
|
|
667
|
+
if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
|
|
668
|
+
stream.openTimer = undefined
|
|
669
|
+
stream.targetSocket.destroy()
|
|
670
|
+
resumeEligibleTargets()
|
|
671
|
+
if (!keepQueuedReset) schedulePump(connection)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** @param {RelayConnection} connection - Connection. @returns {void} */
|
|
675
|
+
function destroyConnection(connection) {
|
|
676
|
+
if (connection.expiryTimer !== undefined) clearTimeout(connection.expiryTimer)
|
|
677
|
+
connection.expiryTimer = undefined
|
|
678
|
+
if (connections.get(connection.id) === connection) connections.delete(connection.id)
|
|
679
|
+
for (const stream of [...connection.streams.values()]) destroyStream(connection, stream)
|
|
680
|
+
connection.physical?.destroy()
|
|
681
|
+
connection.physical = undefined
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** @param {string} reason - Privacy-safe stable reset reason. @returns {void} */
|
|
685
|
+
function recordReset(reason) {
|
|
686
|
+
resets += 1
|
|
687
|
+
resetReasons.set(reason, (resetReasons.get(reason) ?? 0) + 1)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
await new Promise((resolve, reject) => {
|
|
691
|
+
server.once("error", reject)
|
|
692
|
+
server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
|
|
693
|
+
server.off("error", reject)
|
|
694
|
+
resolve(undefined)
|
|
695
|
+
})
|
|
696
|
+
})
|
|
697
|
+
const address = server.address()
|
|
698
|
+
if (address === null || typeof address === "string") throw new Error("Multiplexed relay did not bind to a TCP port")
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
server,
|
|
702
|
+
port: address.port,
|
|
703
|
+
status: () => ({
|
|
704
|
+
physicalAccepts,
|
|
705
|
+
physicalConnections: physicals.size,
|
|
706
|
+
authenticatedConnections,
|
|
707
|
+
connectionIdentities: connections.size,
|
|
708
|
+
connectionGeneration: Math.max(0, ...[...connections.values()].map((connection) => connection.generation)),
|
|
709
|
+
virtualStreams: [...connections.values()].reduce((sum, connection) => sum + connection.streams.size, 0),
|
|
710
|
+
activeTargets,
|
|
711
|
+
queuedBytes: processBufferedBytes + [...connections.values()].reduce((sum, connection) => sum + connection.controlQueuedBytes, 0),
|
|
712
|
+
bufferedBytes: processBufferedBytes,
|
|
713
|
+
bufferLimitBytes: maxTotalBufferBytes,
|
|
714
|
+
openFailures,
|
|
715
|
+
resets,
|
|
716
|
+
resumeSuccesses,
|
|
717
|
+
resumeFailures,
|
|
718
|
+
streamOpenLatencyMs: streamOpenLatencyCount === 0 ? 0 : Math.round(streamOpenLatencyTotalMs / streamOpenLatencyCount),
|
|
719
|
+
resetReasons: Object.fromEntries(resetReasons)
|
|
720
|
+
}),
|
|
721
|
+
closeActiveTransport: async () => {
|
|
722
|
+
await Promise.all([...physicals].map((socket) => new Promise((resolve) => {
|
|
723
|
+
if (socket.destroyed) { resolve(undefined); return }
|
|
724
|
+
socket.once("close", () => resolve(undefined))
|
|
725
|
+
socket.destroy()
|
|
726
|
+
})))
|
|
727
|
+
},
|
|
728
|
+
close: async () => {
|
|
729
|
+
closing = true
|
|
730
|
+
for (const connection of [...connections.values()]) destroyConnection(connection)
|
|
731
|
+
for (const socket of physicals) socket.destroy()
|
|
732
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** @param {MultiplexedRelayOptions} options - Options. @returns {void} */
|
|
738
|
+
function validateOptions(options) {
|
|
739
|
+
if (options?.tokens === undefined || typeof options.tokens.authenticate !== "function" || typeof options.authorizeTarget !== "function") throw new TypeError("V3 relay requires tokens and authorizeTarget")
|
|
740
|
+
for (const [name, value] of Object.entries({
|
|
741
|
+
initialWindowBytes: options.initialWindowBytes,
|
|
742
|
+
maxFrameBytes: options.maxFrameBytes,
|
|
743
|
+
maxStreamBufferBytes: options.maxStreamBufferBytes,
|
|
744
|
+
maxConnectionBufferBytes: options.maxConnectionBufferBytes,
|
|
745
|
+
maxTotalBufferBytes: options.maxTotalBufferBytes,
|
|
746
|
+
maxStreams: options.maxStreams,
|
|
747
|
+
resumeWindowMs: options.resumeWindowMs,
|
|
748
|
+
handshakeTimeoutMs: options.handshakeTimeoutMs,
|
|
749
|
+
maxInputBytesPerConnection: options.maxInputBytesPerConnection,
|
|
750
|
+
targetConnectTimeoutMs: options.targetConnectTimeoutMs
|
|
751
|
+
})) {
|
|
752
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new RangeError(`${name} must be a positive integer`)
|
|
753
|
+
}
|
|
754
|
+
if ((options.maxFrameBytes ?? DEFAULT_FRAME_BYTES) > (options.initialWindowBytes ?? DEFAULT_WINDOW)) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
|
|
755
|
+
if ((options.initialWindowBytes ?? DEFAULT_WINDOW) > (options.maxStreamBufferBytes ?? DEFAULT_STREAM_BYTES)) throw new RangeError("initialWindowBytes must not exceed maxStreamBufferBytes")
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** @param {unknown} value - Target. @returns {Target | undefined} */
|
|
759
|
+
function parseTarget(value) {
|
|
760
|
+
if (typeof value !== "object" || value === null) return undefined
|
|
761
|
+
const target = /** @type {{host?: unknown, port?: unknown}} */ (value)
|
|
762
|
+
if (typeof target.host !== "string" || target.host.length === 0 || target.host.length > 253 || typeof target.port !== "number" || !Number.isInteger(target.port) || target.port < 1 || target.port > 65_535) return undefined
|
|
763
|
+
return {host: target.host, port: target.port}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* @param {import('node:net').Socket} socket - Socket. @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Control body. @returns {Promise<boolean>}
|
|
768
|
+
* @param type
|
|
769
|
+
* @param streamId
|
|
770
|
+
* @param sequence
|
|
771
|
+
* @param body
|
|
772
|
+
*/
|
|
773
|
+
async function sendImmediate(socket, type, streamId, sequence, body) {
|
|
774
|
+
return await writePhysical(socket, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* @param {import('node:net').Socket} socket - Socket. @param {Buffer} bytes - Frame. @returns {Promise<boolean>}
|
|
779
|
+
* @param bytes
|
|
780
|
+
*/
|
|
781
|
+
async function writePhysical(socket, bytes) {
|
|
782
|
+
if (socket.destroyed || !socket.writable) return false
|
|
783
|
+
if (socket.write(bytes)) return true
|
|
784
|
+
return await new Promise((resolve) => {
|
|
785
|
+
const cleanup = () => { socket.off("drain", onDrain); socket.off("close", onClose) }
|
|
786
|
+
const onDrain = () => { cleanup(); resolve(true) }
|
|
787
|
+
const onClose = () => { cleanup(); resolve(false) }
|
|
788
|
+
socket.once("drain", onDrain)
|
|
789
|
+
socket.once("close", onClose)
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** @param {string} code - Stable code. @returns {Error & {code: string}} */
|
|
794
|
+
function protocolError(code) {
|
|
795
|
+
const error = /** @type {Error & {code: string}} */ (new Error("Socketduct v3 protocol violation"))
|
|
796
|
+
error.code = code
|
|
797
|
+
return error
|
|
798
|
+
}
|