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.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. 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
+ }