socketduct 0.1.16 → 0.1.18
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/.env.example +3 -0
- package/changelog.d/20260924120000-wan-route-classes.md +1 -0
- package/changelog.d/20260925120000-reverse-stream-transport-resume.md +1 -0
- package/docs/architecture.md +28 -8
- package/docs/protocol.md +59 -15
- package/docs/reverse-gateway.md +121 -55
- package/docs/security.md +28 -5
- package/docs/session-lifecycle.md +21 -10
- package/docs/socket-api.md +59 -23
- package/package.json +1 -1
- package/src/client/deferred-virtual-socket.js +16 -4
- package/src/client/multiplexed-transport-set.js +106 -65
- package/src/config/environment.js +24 -1
- package/src/reverse/client.js +283 -50
- package/src/reverse/common.js +1 -0
- package/src/reverse/gateway.js +265 -38
- package/src/reverse/hub.js +464 -52
- package/src/reverse/runtime.js +32 -6
- package/test/e2e/reverse-gateway-process.test.js +93 -0
- package/test/integration/reverse-gateway.test.js +576 -28
- package/test/integration/reverse-route-class.test.js +377 -0
- package/test/unit/client/multiplexed-transport-set.test.js +42 -0
- package/test/unit/config/environment.test.js +7 -1
package/src/reverse/hub.js
CHANGED
|
@@ -6,6 +6,7 @@ import {availableCodecs, negotiateCodec, zstdAvailable} from "../protocol/compre
|
|
|
6
6
|
import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
|
|
7
7
|
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
|
|
8
8
|
import {
|
|
9
|
+
asError,
|
|
9
10
|
DEFAULT_FRAME_BYTES,
|
|
10
11
|
DEFAULT_HANDSHAKE_MS,
|
|
11
12
|
DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
|
|
@@ -17,10 +18,15 @@ import {
|
|
|
17
18
|
parseTarget,
|
|
18
19
|
positiveInteger,
|
|
19
20
|
protocolError,
|
|
21
|
+
ROUTE_CLASS,
|
|
20
22
|
STABLE_IDENTIFIER,
|
|
21
23
|
writeSocket
|
|
22
24
|
} from "./common.js"
|
|
23
25
|
|
|
26
|
+
const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
|
|
27
|
+
const DEFAULT_RESUME_MS = 60_000
|
|
28
|
+
const MANIFEST_PAGE_STREAMS = 64
|
|
29
|
+
|
|
24
30
|
/**
|
|
25
31
|
* @typedef {{userId: string, username: string}} Principal
|
|
26
32
|
* @typedef {{host: string, port: number}} Target
|
|
@@ -31,8 +37,11 @@ import {
|
|
|
31
37
|
* @property {{certFile: string, keyFile: string, clientCaFile?: string}} tls - Mandatory outer TLS server configuration.
|
|
32
38
|
* @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeGateway - Stable gateway identity binding.
|
|
33
39
|
* @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeClientGateway - Client-to-gateway selection policy.
|
|
40
|
+
* @property {(input: {principal: Principal, routeClass: string}) => boolean} [authorizeClientRouteClass] - Client-to-route-class selection policy.
|
|
34
41
|
* @property {(input: {principal: Principal, gatewayId: string, target: Target}) => boolean} authorizeOpen - Hub-side route policy.
|
|
42
|
+
* @property {(input: {principal: Principal, routeClass: string, target: Target}) => string[]} [resolveOpenGateways] - Eligible exact gateways for a route-class OPEN.
|
|
35
43
|
* @property {string[]} [knownGatewayIds] - Configured IDs that may be temporarily unavailable.
|
|
44
|
+
* @property {string[]} [knownRouteClasses] - Configured route classes available to authorized clients.
|
|
36
45
|
* @property {number} [initialWindowBytes] - Per-direction flow-control window.
|
|
37
46
|
* @property {number} [maxFrameBytes] - Maximum uncompressed DATA frame.
|
|
38
47
|
* @property {number} [maxStreams] - Process-wide bridge limit.
|
|
@@ -40,6 +49,8 @@ import {
|
|
|
40
49
|
* @property {number} [maxConnectionIdentities] - Retained stale-generation identity bound.
|
|
41
50
|
* @property {number} [handshakeTimeoutMs] - Authentication deadline.
|
|
42
51
|
* @property {number} [maxInputBytesPerConnection] - Pre-authentication byte bound.
|
|
52
|
+
* @property {number} [maxOutputBytesPerConnection] - Per-physical queued hub output bound.
|
|
53
|
+
* @property {number} [resumeWindowMs] - Detached reverse-stream retention window.
|
|
43
54
|
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
|
|
44
55
|
* @typedef {object} HubPhysical
|
|
45
56
|
* @property {import('node:tls').TLSSocket} socket
|
|
@@ -49,27 +60,50 @@ import {
|
|
|
49
60
|
* @property {number} generation
|
|
50
61
|
* @property {string | undefined} codec
|
|
51
62
|
* @property {string | undefined} gatewayId
|
|
63
|
+
* @property {string | undefined} routeClass
|
|
52
64
|
* @property {Map<number, Bridge>} streams
|
|
53
65
|
* @property {number} lastClientStreamId
|
|
54
66
|
* @property {number} nextGatewayStreamId
|
|
55
67
|
* @property {boolean} authenticated
|
|
56
68
|
* @property {boolean} closing
|
|
69
|
+
* @property {boolean} retainOnDetach
|
|
70
|
+
* @property {string | undefined} detachCode
|
|
71
|
+
* @property {boolean} resumeRequired
|
|
72
|
+
* @property {number} resumeNextPage
|
|
73
|
+
* @property {Array<Record<string, unknown>>} resumeItems
|
|
74
|
+
* @property {boolean} resumeFinal
|
|
75
|
+
* @property {Array<{streamId: number, bridge: Bridge | undefined, code?: string}>} resumeEntries
|
|
76
|
+
* @property {ReturnType<typeof setTimeout> | undefined} resumeTimer
|
|
57
77
|
* @property {(() => void) | undefined} clearHandshake
|
|
78
|
+
* @property {HubOutputItem[]} outputQueue
|
|
79
|
+
* @property {number} outputBytes
|
|
80
|
+
* @property {boolean} outputWriting
|
|
81
|
+
* @typedef {object} HubOutputItem
|
|
82
|
+
* @property {Buffer} bytes
|
|
83
|
+
* @property {(delivered: boolean) => void} resolve
|
|
58
84
|
* @typedef {object} GatewayOwner
|
|
59
85
|
* @property {string} gatewayId
|
|
60
86
|
* @property {string} connectionId
|
|
61
87
|
* @property {string} userId
|
|
62
88
|
* @property {number} generation
|
|
63
89
|
* @property {HubPhysical | undefined} physical
|
|
90
|
+
* @property {Map<number, Bridge>} streams
|
|
91
|
+
* @property {ReturnType<typeof setTimeout> | undefined} expiryTimer
|
|
64
92
|
* @typedef {object} ClientOwner
|
|
65
93
|
* @property {string} connectionId
|
|
66
94
|
* @property {string} userId
|
|
67
95
|
* @property {number} generation
|
|
68
96
|
* @property {HubPhysical | undefined} physical
|
|
97
|
+
* @property {Map<number, Bridge>} streams
|
|
98
|
+
* @property {string | undefined} gatewayId
|
|
99
|
+
* @property {string | undefined} routeClass
|
|
100
|
+
* @property {ReturnType<typeof setTimeout> | undefined} expiryTimer
|
|
69
101
|
* @typedef {object} Bridge
|
|
70
102
|
* @property {HubPhysical} client
|
|
103
|
+
* @property {ClientOwner} clientOwner
|
|
71
104
|
* @property {number} clientStreamId
|
|
72
105
|
* @property {HubPhysical} gateway
|
|
106
|
+
* @property {GatewayOwner} gatewayOwner
|
|
73
107
|
* @property {number} gatewayStreamId
|
|
74
108
|
* @property {string} identifier
|
|
75
109
|
* @property {Target} target
|
|
@@ -82,6 +116,9 @@ import {
|
|
|
82
116
|
* @property {boolean} gatewayEnded
|
|
83
117
|
* @property {AdaptiveCompressor} clientToGatewayCompressor
|
|
84
118
|
* @property {AdaptiveCompressor} gatewayToClientCompressor
|
|
119
|
+
* @property {{generation: number, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean} | undefined} clientResume
|
|
120
|
+
* @property {{generation: number, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean} | undefined} gatewayResume
|
|
121
|
+
* @property {string | undefined} reconciledResume
|
|
85
122
|
*/
|
|
86
123
|
|
|
87
124
|
/** Starts the Server3 reverse hub. */
|
|
@@ -92,6 +129,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
92
129
|
const maxStreams = positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
|
|
93
130
|
const maxIdentities = positiveInteger(options.maxConnectionIdentities, "maxConnectionIdentities", 4096)
|
|
94
131
|
const maxPhysicalConnections = positiveInteger(options.maxPhysicalConnections, "maxPhysicalConnections", 8192)
|
|
132
|
+
const maxOutputBytes = positiveInteger(options.maxOutputBytesPerConnection, "maxOutputBytesPerConnection", DEFAULT_OUTPUT_BUFFER_BYTES)
|
|
133
|
+
const resumeWindowMs = positiveInteger(options.resumeWindowMs, "resumeWindowMs", DEFAULT_RESUME_MS)
|
|
95
134
|
/** @type {Map<string, GatewayOwner>} */
|
|
96
135
|
const gateways = new Map()
|
|
97
136
|
/** @type {Map<string, ClientOwner>} */
|
|
@@ -153,12 +192,24 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
153
192
|
generation: 0,
|
|
154
193
|
codec: undefined,
|
|
155
194
|
gatewayId: undefined,
|
|
195
|
+
routeClass: undefined,
|
|
156
196
|
streams: new Map(),
|
|
157
197
|
lastClientStreamId: 0,
|
|
158
198
|
nextGatewayStreamId: 2,
|
|
159
199
|
authenticated: false,
|
|
160
200
|
closing: false,
|
|
161
|
-
|
|
201
|
+
retainOnDetach: true,
|
|
202
|
+
detachCode: undefined,
|
|
203
|
+
resumeRequired: false,
|
|
204
|
+
resumeNextPage: 0,
|
|
205
|
+
resumeItems: [],
|
|
206
|
+
resumeFinal: false,
|
|
207
|
+
resumeEntries: [],
|
|
208
|
+
resumeTimer: undefined,
|
|
209
|
+
clearHandshake: undefined,
|
|
210
|
+
outputQueue: [],
|
|
211
|
+
outputBytes: 0,
|
|
212
|
+
outputWriting: false
|
|
162
213
|
}
|
|
163
214
|
physicals.add(physical)
|
|
164
215
|
const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4) * 2})
|
|
@@ -174,10 +225,9 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
174
225
|
if (!physical.authenticated && unauthenticatedBytes > (options.maxInputBytesPerConnection ?? DEFAULT_MAX_INPUT_BYTES)) throw protocolError("INPUT_LIMIT_EXCEEDED")
|
|
175
226
|
const frames = decoder.push(chunk)
|
|
176
227
|
for (const frame of frames) await handleFrame(physical, frame)
|
|
177
|
-
}).catch(
|
|
228
|
+
}).catch((error) => {
|
|
178
229
|
const code = typeof /** @type {{code?: unknown}} */ (error).code === "string" ? /** @type {{code: string}} */ (error).code : "PROTOCOL_VIOLATION"
|
|
179
|
-
|
|
180
|
-
socket.destroy()
|
|
230
|
+
terminate(physical, code)
|
|
181
231
|
}).finally(() => {
|
|
182
232
|
if (!socket.destroyed && !physical.closing) socket.resume()
|
|
183
233
|
})
|
|
@@ -185,7 +235,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
185
235
|
socket.on("error", () => {})
|
|
186
236
|
socket.once("close", () => {
|
|
187
237
|
clearTimeout(handshakeTimer)
|
|
188
|
-
void queue.finally(() => detachPhysical(physical, "TRANSPORT_CLOSED"))
|
|
238
|
+
void queue.finally(() => detachPhysical(physical, physical.detachCode ?? "TRANSPORT_CLOSED"))
|
|
189
239
|
})
|
|
190
240
|
physical.clearHandshake = () => clearTimeout(handshakeTimer)
|
|
191
241
|
}
|
|
@@ -227,6 +277,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
227
277
|
}
|
|
228
278
|
if (frame.type === V3FrameType.PONG) return
|
|
229
279
|
if (frame.type === V3FrameType.GOAWAY) {
|
|
280
|
+
physical.retainOnDetach = false
|
|
230
281
|
physical.socket.end()
|
|
231
282
|
return
|
|
232
283
|
}
|
|
@@ -237,12 +288,17 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
237
288
|
await registerGateway(physical, frame)
|
|
238
289
|
return
|
|
239
290
|
}
|
|
240
|
-
if (physical.role === "client" && physical.gatewayId === undefined) {
|
|
291
|
+
if (physical.role === "client" && physical.gatewayId === undefined && physical.routeClass === undefined) {
|
|
241
292
|
if (frame.type !== V3FrameType.GATEWAY_SELECT) throw protocolError("GATEWAY_SELECTION_REQUIRED")
|
|
242
|
-
await
|
|
293
|
+
await selectClientRoute(physical, frame)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (physical.resumeRequired && frame.type !== V3FrameType.RESUME) throw protocolError("RESUME_REQUIRED")
|
|
297
|
+
if (frame.type === V3FrameType.RESUME) {
|
|
298
|
+
await receiveResumeManifest(physical, frame)
|
|
243
299
|
return
|
|
244
300
|
}
|
|
245
|
-
if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.
|
|
301
|
+
if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.RESUMED) throw protocolError("UNSUPPORTED_FRAME")
|
|
246
302
|
if (physical.role === "client" && frame.type === V3FrameType.OPEN) {
|
|
247
303
|
await openBridge(physical, frame)
|
|
248
304
|
return
|
|
@@ -260,13 +316,30 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
260
316
|
if (existing === undefined && clients.size >= limit) evictDisconnectedClientIdentity()
|
|
261
317
|
if (existing === undefined && clients.size >= limit) throw protocolError("IDENTITY_LIMIT")
|
|
262
318
|
if (existing?.physical !== undefined) terminate(existing.physical, "CONNECTION_REPLACED")
|
|
263
|
-
|
|
319
|
+
if (existing?.expiryTimer !== undefined) clearTimeout(existing.expiryTimer)
|
|
320
|
+
const owner = existing ?? {
|
|
321
|
+
connectionId: id,
|
|
322
|
+
userId: principal.userId,
|
|
323
|
+
generation: physical.generation,
|
|
324
|
+
physical: undefined,
|
|
325
|
+
streams: new Map(),
|
|
326
|
+
gatewayId: undefined,
|
|
327
|
+
routeClass: undefined,
|
|
328
|
+
expiryTimer: undefined
|
|
329
|
+
}
|
|
330
|
+
owner.generation = physical.generation
|
|
331
|
+
owner.physical = physical
|
|
332
|
+
owner.expiryTimer = undefined
|
|
333
|
+
physical.streams = owner.streams
|
|
334
|
+
physical.lastClientStreamId = Math.max(0, ...owner.streams.keys())
|
|
335
|
+
physical.resumeRequired = existing !== undefined && owner.streams.size > 0
|
|
336
|
+
clients.set(id, owner)
|
|
264
337
|
}
|
|
265
338
|
|
|
266
339
|
/** Evicts the oldest disconnected client identity while active owners remain fenced. */
|
|
267
340
|
function evictDisconnectedClientIdentity() {
|
|
268
341
|
for (const [id, owner] of clients) {
|
|
269
|
-
if (owner.physical !== undefined) continue
|
|
342
|
+
if (owner.physical !== undefined || owner.streams.size > 0) continue
|
|
270
343
|
clients.delete(id)
|
|
271
344
|
return
|
|
272
345
|
}
|
|
@@ -296,30 +369,220 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
296
369
|
}
|
|
297
370
|
if (existing === undefined && gateways.size >= maxIdentities) throw protocolError("IDENTITY_LIMIT")
|
|
298
371
|
if (existing?.physical !== undefined) terminate(existing.physical, "GATEWAY_REPLACED")
|
|
372
|
+
if (existing?.expiryTimer !== undefined) clearTimeout(existing.expiryTimer)
|
|
299
373
|
physical.gatewayId = request.gatewayId
|
|
300
|
-
|
|
374
|
+
const owner = existing ?? {
|
|
301
375
|
gatewayId: request.gatewayId,
|
|
302
376
|
connectionId,
|
|
303
377
|
userId: physical.principal.userId,
|
|
304
378
|
generation: physical.generation,
|
|
305
|
-
physical
|
|
306
|
-
|
|
379
|
+
physical: undefined,
|
|
380
|
+
streams: new Map(),
|
|
381
|
+
expiryTimer: undefined
|
|
382
|
+
}
|
|
383
|
+
owner.generation = physical.generation
|
|
384
|
+
owner.physical = physical
|
|
385
|
+
owner.expiryTimer = undefined
|
|
386
|
+
physical.streams = owner.streams
|
|
387
|
+
physical.nextGatewayStreamId = Math.max(0, ...owner.streams.keys()) + 2
|
|
388
|
+
physical.resumeRequired = existing !== undefined && owner.streams.size > 0
|
|
389
|
+
gateways.set(request.gatewayId, owner)
|
|
307
390
|
gatewayRegistrations += 1
|
|
308
|
-
await sendControl(physical, V3FrameType.GATEWAY_REGISTERED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
|
|
391
|
+
await sendControl(physical, V3FrameType.GATEWAY_REGISTERED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation, resumed: physical.resumeRequired})
|
|
392
|
+
armResumeTimeout(physical)
|
|
309
393
|
}
|
|
310
394
|
|
|
311
395
|
/** @param {HubPhysical} physical - Authenticated client. @param {import('../protocol/v3-frame.js').V3Frame} frame - Selection frame. */
|
|
312
|
-
async function
|
|
396
|
+
async function selectClientRoute(physical, frame) {
|
|
313
397
|
if (frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
314
398
|
const request = decodeV3Control(frame.payload)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
if (
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
399
|
+
const hasGatewayId = Object.hasOwn(request, "gatewayId")
|
|
400
|
+
const hasRouteClass = Object.hasOwn(request, "routeClass")
|
|
401
|
+
if (hasGatewayId === hasRouteClass || physical.principal === undefined) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
402
|
+
const clientOwner = clients.get(/** @type {string} */ (physical.connectionId))
|
|
403
|
+
if (clientOwner === undefined || clientOwner.physical !== physical) throw protocolError("STALE_GENERATION")
|
|
404
|
+
if (hasGatewayId) {
|
|
405
|
+
if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
406
|
+
if (options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
|
|
407
|
+
const owner = gateways.get(request.gatewayId)
|
|
408
|
+
const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
|
|
409
|
+
if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
|
|
410
|
+
const retainedSelection = clientOwner.gatewayId === request.gatewayId && clientOwner.streams.size > 0
|
|
411
|
+
const selectable = owner?.physical !== undefined && !owner.physical.socket.destroyed && !owner.physical.closing && !owner.physical.resumeRequired
|
|
412
|
+
if (!selectable && !retainedSelection) throw protocolError("GATEWAY_UNAVAILABLE")
|
|
413
|
+
if (clientOwner.routeClass !== undefined || (clientOwner.gatewayId !== undefined && clientOwner.gatewayId !== request.gatewayId)) throw protocolError("GATEWAY_SELECTION_MISMATCH")
|
|
414
|
+
physical.gatewayId = request.gatewayId
|
|
415
|
+
clientOwner.gatewayId = request.gatewayId
|
|
416
|
+
await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation, resumed: physical.resumeRequired})
|
|
417
|
+
armResumeTimeout(physical)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
if (typeof request.routeClass !== "string" || !ROUTE_CLASS.test(request.routeClass)) throw protocolError("INVALID_ROUTE_CLASS_SELECTION")
|
|
421
|
+
if (options.authorizeClientRouteClass?.({principal: physical.principal, routeClass: request.routeClass}) !== true) throw protocolError("ROUTE_CLASS_UNAUTHORIZED")
|
|
422
|
+
if (options.knownRouteClasses?.includes(request.routeClass) !== true) throw protocolError("UNKNOWN_ROUTE_CLASS")
|
|
423
|
+
if (clientOwner.gatewayId !== undefined || (clientOwner.routeClass !== undefined && clientOwner.routeClass !== request.routeClass)) throw protocolError("GATEWAY_SELECTION_MISMATCH")
|
|
424
|
+
physical.routeClass = request.routeClass
|
|
425
|
+
clientOwner.routeClass = request.routeClass
|
|
426
|
+
await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation, resumed: physical.resumeRequired})
|
|
427
|
+
armResumeTimeout(physical)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** @param {HubPhysical} physical - Reconnecting reverse peer. @param {import('../protocol/v3-frame.js').V3Frame} frame - Resume manifest page. */
|
|
431
|
+
async function receiveResumeManifest(physical, frame) {
|
|
432
|
+
if (!physical.resumeRequired || physical.resumeFinal || frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_RESUME_MANIFEST")
|
|
433
|
+
const manifest = decodeV3Control(frame.payload)
|
|
434
|
+
if (manifest.page !== physical.resumeNextPage || !Array.isArray(manifest.streams) || manifest.streams.length > MANIFEST_PAGE_STREAMS || physical.resumeItems.length + manifest.streams.length > maxStreams) throw protocolError("INVALID_RESUME_MANIFEST")
|
|
435
|
+
physical.resumeNextPage += 1
|
|
436
|
+
physical.resumeItems.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)))
|
|
437
|
+
if (manifest.final !== true) return
|
|
438
|
+
const presented = new Set()
|
|
439
|
+
physical.resumeEntries = []
|
|
440
|
+
for (const value of physical.resumeItems) {
|
|
441
|
+
const item = parseResumeItem(value)
|
|
442
|
+
if (presented.has(item.streamId)) throw protocolError("DUPLICATE_RESUME_STREAM")
|
|
443
|
+
presented.add(item.streamId)
|
|
444
|
+
const bridge = physical.streams.get(item.streamId)
|
|
445
|
+
if (bridge === undefined) {
|
|
446
|
+
physical.resumeEntries.push({streamId: item.streamId, bridge: undefined, code: "RESUME_SESSION_LOST"})
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
if (bridge.identifier !== item.identifier) {
|
|
450
|
+
closeBridge(bridge, "RESUME_IDENTITY_MISMATCH", physical)
|
|
451
|
+
physical.resumeEntries.push({streamId: item.streamId, bridge: undefined, code: "RESUME_IDENTITY_MISMATCH"})
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
if (physical.role === "client") {
|
|
455
|
+
if (bridge.clientOwner.physical !== physical) throw protocolError("STALE_GENERATION")
|
|
456
|
+
bridge.client = physical
|
|
457
|
+
bridge.clientResume = {generation: physical.generation, ...item}
|
|
458
|
+
} else {
|
|
459
|
+
if (bridge.gatewayOwner.physical !== physical) throw protocolError("STALE_GENERATION")
|
|
460
|
+
bridge.gateway = physical
|
|
461
|
+
bridge.gatewayResume = {generation: physical.generation, ...item}
|
|
462
|
+
}
|
|
463
|
+
physical.resumeEntries.push({streamId: item.streamId, bridge})
|
|
464
|
+
}
|
|
465
|
+
for (const bridge of [...physical.streams.values()]) {
|
|
466
|
+
const streamId = physical.role === "client" ? bridge.clientStreamId : bridge.gatewayStreamId
|
|
467
|
+
if (!presented.has(streamId)) closeBridge(bridge, "RESUME_STREAM_OMITTED", physical)
|
|
468
|
+
}
|
|
469
|
+
physical.resumeItems = []
|
|
470
|
+
physical.resumeFinal = true
|
|
471
|
+
await tryCompleteResumes()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Completes physical resume responses only after both logical endpoints have declared durable positions. */
|
|
475
|
+
async function tryCompleteResumes() {
|
|
476
|
+
while (true) {
|
|
477
|
+
const candidates = [...physicals].filter((physical) => physical.resumeRequired && physical.resumeFinal && !physical.socket.destroyed && !physical.resumeEntries.some(({bridge}) => bridge !== undefined && bridgeStillOwned(bridge) && (bridge.clientResume === undefined || bridge.gatewayResume === undefined)))
|
|
478
|
+
const readySet = new Set(candidates)
|
|
479
|
+
let pruned
|
|
480
|
+
do {
|
|
481
|
+
pruned = false
|
|
482
|
+
for (const physical of [...readySet]) {
|
|
483
|
+
const hasUnreadyPeer = physical.resumeEntries.some(({bridge}) => bridge !== undefined && bridgeStillOwned(bridge) && (!readySet.has(bridge.client) || !readySet.has(bridge.gateway)))
|
|
484
|
+
if (!hasUnreadyPeer) continue
|
|
485
|
+
readySet.delete(physical)
|
|
486
|
+
pruned = true
|
|
487
|
+
}
|
|
488
|
+
} while (pruned)
|
|
489
|
+
const ready = [...readySet]
|
|
490
|
+
if (ready.length === 0) return
|
|
491
|
+
for (const physical of ready) {
|
|
492
|
+
clearResumeTimeout(physical)
|
|
493
|
+
physical.resumeFinal = false
|
|
494
|
+
physical.resumeRequired = false
|
|
495
|
+
}
|
|
496
|
+
/** @type {Promise<boolean>[]} */
|
|
497
|
+
const deliveries = []
|
|
498
|
+
for (const physical of ready) {
|
|
499
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
500
|
+
const results = []
|
|
501
|
+
for (const entry of physical.resumeEntries) {
|
|
502
|
+
const bridge = entry.bridge
|
|
503
|
+
if (bridge === undefined || !bridgeStillOwned(bridge)) {
|
|
504
|
+
results.push({streamId: entry.streamId, status: "reset", code: entry.code ?? "RESUME_SESSION_LOST"})
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
reconcileBridge(bridge)
|
|
509
|
+
} catch {
|
|
510
|
+
closeBridge(bridge, "RESUME_STATE_MISMATCH")
|
|
511
|
+
results.push({streamId: entry.streamId, status: "reset", code: "RESUME_STATE_MISMATCH"})
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
results.push({
|
|
515
|
+
streamId: entry.streamId,
|
|
516
|
+
status: "resumed",
|
|
517
|
+
clientToGatewaySequence: bridge.clientToGatewaySequence,
|
|
518
|
+
clientToGatewayAck: bridge.clientToGatewayAck,
|
|
519
|
+
gatewayToClientSequence: bridge.gatewayToClientSequence,
|
|
520
|
+
gatewayToClientAck: bridge.gatewayToClientAck,
|
|
521
|
+
clientEnded: bridge.clientEnded,
|
|
522
|
+
gatewayEnded: bridge.gatewayEnded,
|
|
523
|
+
opened: bridge.state !== "opening"
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
const pageCount = Math.max(1, Math.ceil(results.length / MANIFEST_PAGE_STREAMS))
|
|
527
|
+
for (let page = 0; page < pageCount; page += 1) {
|
|
528
|
+
deliveries.push(sendControl(physical, V3FrameType.RESUMED, 0, physical.generation, {
|
|
529
|
+
page,
|
|
530
|
+
final: page === pageCount - 1,
|
|
531
|
+
streams: results.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
|
|
532
|
+
}))
|
|
533
|
+
}
|
|
534
|
+
physical.resumeEntries = []
|
|
535
|
+
physical.resumeNextPage = 0
|
|
536
|
+
}
|
|
537
|
+
await Promise.all(deliveries)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** @param {Bridge} bridge - Retained bridge. @returns {boolean} Whether both owners still retain it. */
|
|
542
|
+
function bridgeStillOwned(bridge) {
|
|
543
|
+
return bridge.clientOwner.streams.get(bridge.clientStreamId) === bridge && bridge.gatewayOwner.streams.get(bridge.gatewayStreamId) === bridge
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Bounds an authenticated peer that reconnects but never completes its resume manifest. */
|
|
547
|
+
function armResumeTimeout(/** @type {HubPhysical} */ physical) {
|
|
548
|
+
clearResumeTimeout(physical)
|
|
549
|
+
if (!physical.resumeRequired || physical.socket.destroyed) return
|
|
550
|
+
physical.resumeTimer = setTimeout(() => terminate(physical, "RESUME_WINDOW_EXPIRED"), resumeWindowMs)
|
|
551
|
+
physical.resumeTimer.unref?.()
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Clears one physical resume deadline. */
|
|
555
|
+
function clearResumeTimeout(/** @type {HubPhysical} */ physical) {
|
|
556
|
+
if (physical.resumeTimer !== undefined) clearTimeout(physical.resumeTimer)
|
|
557
|
+
physical.resumeTimer = undefined
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Reconciles one bridge to receiver-committed byte positions for a fresh physical generation. */
|
|
561
|
+
function reconcileBridge(/** @type {Bridge} */ bridge) {
|
|
562
|
+
const client = bridge.clientResume
|
|
563
|
+
const gateway = bridge.gatewayResume
|
|
564
|
+
if (client === undefined || gateway === undefined) throw protocolError("RESUME_STATE_MISMATCH")
|
|
565
|
+
const key = `${client.generation}:${gateway.generation}`
|
|
566
|
+
if (bridge.reconciledResume === key) return
|
|
567
|
+
if (gateway.clientToGatewaySequence > client.clientToGatewaySequence || client.clientToGatewayAck > gateway.clientToGatewayAck || client.gatewayToClientSequence > gateway.gatewayToClientSequence || gateway.gatewayToClientAck > client.gatewayToClientAck) throw protocolError("RESUME_STATE_MISMATCH")
|
|
568
|
+
if ((gateway.remoteEnded && !client.localEnded) || (client.remoteEnded && !gateway.localEnded)) throw protocolError("RESUME_STATE_MISMATCH")
|
|
569
|
+
bridge.clientToGatewaySequence = gateway.clientToGatewaySequence
|
|
570
|
+
bridge.clientToGatewayAck = gateway.clientToGatewayAck
|
|
571
|
+
bridge.gatewayToClientSequence = client.gatewayToClientSequence
|
|
572
|
+
bridge.gatewayToClientAck = client.gatewayToClientAck
|
|
573
|
+
bridge.clientEnded = gateway.remoteEnded
|
|
574
|
+
bridge.gatewayEnded = client.remoteEnded
|
|
575
|
+
bridge.clientToGatewayCompressor = new AdaptiveCompressor()
|
|
576
|
+
bridge.gatewayToClientCompressor = new AdaptiveCompressor()
|
|
577
|
+
bridge.reconciledResume = key
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @param {Record<string, unknown>} value - Resume manifest entry. */
|
|
581
|
+
function parseResumeItem(value) {
|
|
582
|
+
const item = /** @type {{streamId?: unknown, identifier?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, localEnded?: unknown, remoteEnded?: unknown}} */ (value)
|
|
583
|
+
const sequences = [item.clientToGatewaySequence, item.clientToGatewayAck, item.gatewayToClientSequence, item.gatewayToClientAck]
|
|
584
|
+
if (!Number.isInteger(item.streamId) || /** @type {number} */ (item.streamId) <= 0 || typeof item.identifier !== "string" || !STABLE_IDENTIFIER.test(item.identifier) || !sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0) || /** @type {number} */ (item.clientToGatewayAck) > /** @type {number} */ (item.clientToGatewaySequence) || /** @type {number} */ (item.gatewayToClientAck) > /** @type {number} */ (item.gatewayToClientSequence) || typeof item.localEnded !== "boolean" || typeof item.remoteEnded !== "boolean") throw protocolError("INVALID_RESUME_MANIFEST")
|
|
585
|
+
return /** @type {{streamId: number, identifier: string, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean}} */ (item)
|
|
323
586
|
}
|
|
324
587
|
|
|
325
588
|
/** @param {HubPhysical} client - Client physical. @param {import('../protocol/v3-frame.js').V3Frame} frame - OPEN. */
|
|
@@ -329,20 +592,51 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
329
592
|
client.lastClientStreamId = frame.streamId
|
|
330
593
|
const request = decodeV3Control(frame.payload)
|
|
331
594
|
const target = parseTarget(request.target)
|
|
332
|
-
if (target === undefined || typeof request.
|
|
595
|
+
if (target === undefined || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || client.principal === undefined) {
|
|
333
596
|
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
|
|
334
597
|
return
|
|
335
598
|
}
|
|
336
|
-
|
|
599
|
+
let gatewayId
|
|
600
|
+
if (client.routeClass !== undefined) {
|
|
601
|
+
if (request.routeClass !== client.routeClass || Object.hasOwn(request, "gatewayId")) {
|
|
602
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "ROUTE_CLASS_MISMATCH"})
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
const candidates = options.resolveOpenGateways?.({principal: client.principal, routeClass: client.routeClass, target})
|
|
606
|
+
if (!Array.isArray(candidates) || candidates.some((candidate) => typeof candidate !== "string" || !GATEWAY_ID.test(candidate))) {
|
|
607
|
+
rejectedOpens += 1
|
|
608
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_ROUTE_RESOLUTION"})
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
if (candidates.length === 0) {
|
|
612
|
+
rejectedOpens += 1
|
|
613
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
if (candidates.length !== 1 || new Set(candidates).size !== 1) {
|
|
617
|
+
rejectedOpens += 1
|
|
618
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "AMBIGUOUS_ROUTE"})
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
gatewayId = /** @type {string} */ (candidates[0])
|
|
622
|
+
} else {
|
|
623
|
+
if (typeof request.gatewayId !== "string" || request.gatewayId !== client.gatewayId || !GATEWAY_ID.test(request.gatewayId) || Object.hasOwn(request, "routeClass")) {
|
|
624
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
gatewayId = request.gatewayId
|
|
628
|
+
}
|
|
629
|
+
if (options.authorizeOpen({principal: client.principal, gatewayId, target}) !== true) {
|
|
337
630
|
rejectedOpens += 1
|
|
338
631
|
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
|
|
339
632
|
return
|
|
340
633
|
}
|
|
341
|
-
const gatewayOwner = gateways.get(
|
|
634
|
+
const gatewayOwner = gateways.get(gatewayId)
|
|
342
635
|
const gateway = gatewayOwner?.physical
|
|
343
|
-
if (gateway === undefined || gateway.socket.destroyed || gateway.gatewayId !==
|
|
636
|
+
if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.closing || gateway.resumeRequired || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
|
|
344
637
|
rejectedOpens += 1
|
|
345
|
-
|
|
638
|
+
const known = gatewayOwner !== undefined || options.knownGatewayIds?.includes(gatewayId) === true
|
|
639
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID"})
|
|
346
640
|
return
|
|
347
641
|
}
|
|
348
642
|
const gatewayStreamId = gateway.nextGatewayStreamId
|
|
@@ -351,11 +645,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
351
645
|
return
|
|
352
646
|
}
|
|
353
647
|
gateway.nextGatewayStreamId += 2
|
|
648
|
+
const clientOwner = clients.get(/** @type {string} */ (client.connectionId))
|
|
649
|
+
if (clientOwner === undefined || clientOwner.physical !== client) throw protocolError("STALE_GENERATION")
|
|
354
650
|
/** @type {Bridge} */
|
|
355
651
|
const bridge = {
|
|
356
652
|
client,
|
|
653
|
+
clientOwner,
|
|
357
654
|
clientStreamId: frame.streamId,
|
|
358
655
|
gateway,
|
|
656
|
+
gatewayOwner,
|
|
359
657
|
gatewayStreamId,
|
|
360
658
|
identifier: request.identifier,
|
|
361
659
|
target,
|
|
@@ -367,12 +665,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
367
665
|
clientEnded: false,
|
|
368
666
|
gatewayEnded: false,
|
|
369
667
|
clientToGatewayCompressor: new AdaptiveCompressor(),
|
|
370
|
-
gatewayToClientCompressor: new AdaptiveCompressor()
|
|
668
|
+
gatewayToClientCompressor: new AdaptiveCompressor(),
|
|
669
|
+
clientResume: undefined,
|
|
670
|
+
gatewayResume: undefined,
|
|
671
|
+
reconciledResume: undefined
|
|
371
672
|
}
|
|
372
|
-
|
|
373
|
-
|
|
673
|
+
clientOwner.streams.set(frame.streamId, bridge)
|
|
674
|
+
gatewayOwner.streams.set(gatewayStreamId, bridge)
|
|
374
675
|
bridges += 1
|
|
375
|
-
|
|
676
|
+
forwardControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
|
|
376
677
|
}
|
|
377
678
|
|
|
378
679
|
/** @param {HubPhysical} source - Frame source. @param {import('../protocol/v3-frame.js').V3Frame} frame - Stream frame. */
|
|
@@ -390,7 +691,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
390
691
|
const detail = decodeV3Control(frame.payload)
|
|
391
692
|
if (detail.identifier !== bridge.identifier) throw protocolError("OPEN_IDENTIFIER_MISMATCH")
|
|
392
693
|
bridge.state = "open"
|
|
393
|
-
|
|
694
|
+
forwardControl(destination, V3FrameType.OPENED, destinationStreamId, 0, {identifier: bridge.identifier, initialWindow})
|
|
394
695
|
return
|
|
395
696
|
}
|
|
396
697
|
if (bridge.state === "opening" && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_NOT_OPEN")
|
|
@@ -400,12 +701,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
400
701
|
if (!Number.isSafeInteger(frame.sequence) || frame.sequence < previous || frame.sequence > maximum) throw protocolError("INVALID_ACK")
|
|
401
702
|
if (fromClient) bridge.gatewayToClientAck = frame.sequence
|
|
402
703
|
else bridge.clientToGatewayAck = frame.sequence
|
|
403
|
-
if (bridge.state !== "closing")
|
|
704
|
+
if (bridge.state !== "closing") forwardControl(destination, V3FrameType.ACK, destinationStreamId, frame.sequence, {})
|
|
404
705
|
return
|
|
405
706
|
}
|
|
406
707
|
if (frame.type === V3FrameType.WINDOW) {
|
|
407
708
|
if (frame.sequence <= 0 || frame.sequence > initialWindow) throw protocolError("INVALID_WINDOW")
|
|
408
|
-
if (bridge.state !== "closing")
|
|
709
|
+
if (bridge.state !== "closing") forwardControl(destination, V3FrameType.WINDOW, destinationStreamId, frame.sequence, {})
|
|
409
710
|
return
|
|
410
711
|
}
|
|
411
712
|
if (bridge.state === "closing") {
|
|
@@ -426,8 +727,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
426
727
|
const encoded = await encodeV3Data(raw, destination.codec, fromClient ? bridge.clientToGatewayCompressor : bridge.gatewayToClientCompressor, options.compressionLimits)
|
|
427
728
|
rawDataBytes += encoded.rawBytes
|
|
428
729
|
wireDataBytes += encoded.wireBytes
|
|
429
|
-
|
|
430
|
-
if (!delivered) closeBridge(bridge, "DESTINATION_CLOSED", source)
|
|
730
|
+
forwardOutput(destination, encodeV3Frame({type: V3FrameType.DATA, streamId: destinationStreamId, sequence: frame.sequence, flags: encoded.flags}, encoded.payload))
|
|
431
731
|
return
|
|
432
732
|
}
|
|
433
733
|
if (frame.type === V3FrameType.END) {
|
|
@@ -436,14 +736,14 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
436
736
|
if (fromClient) bridge.clientEnded = true
|
|
437
737
|
else bridge.gatewayEnded = true
|
|
438
738
|
bridge.state = "half-closed"
|
|
439
|
-
|
|
739
|
+
forwardControl(destination, V3FrameType.END, destinationStreamId, frame.sequence, {})
|
|
440
740
|
return
|
|
441
741
|
}
|
|
442
742
|
if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) {
|
|
443
743
|
const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
|
|
444
744
|
if ((frame.type === V3FrameType.CLOSE && frame.sequence !== expected) || (frame.type === V3FrameType.RESET && frame.sequence !== 0)) throw protocolError("INVALID_STREAM_CLOSE")
|
|
445
745
|
const detail = frame.payload.length === 0 ? {} : decodeV3Control(frame.payload)
|
|
446
|
-
|
|
746
|
+
forwardControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
|
|
447
747
|
if (frame.type === V3FrameType.RESET || fromClient) removeBridge(bridge)
|
|
448
748
|
else bridge.state = "closing"
|
|
449
749
|
return
|
|
@@ -453,11 +753,21 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
453
753
|
|
|
454
754
|
/** @param {Bridge} bridge - Stream. */
|
|
455
755
|
function removeBridge(bridge) {
|
|
456
|
-
const existed = bridge.
|
|
457
|
-
bridge.
|
|
756
|
+
const existed = bridge.clientOwner.streams.delete(bridge.clientStreamId)
|
|
757
|
+
bridge.gatewayOwner.streams.delete(bridge.gatewayStreamId)
|
|
458
758
|
if (existed) bridges = Math.max(0, bridges - 1)
|
|
459
759
|
}
|
|
460
760
|
|
|
761
|
+
/** @param {HubPhysical} destination - Frame destination. @param {Buffer} bytes - Encoded frame. */
|
|
762
|
+
function forwardOutput(destination, bytes) {
|
|
763
|
+
void enqueueOutput(destination, bytes)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** @param {HubPhysical} destination - Frame destination. @param {number} type - Frame type. @param {number} streamId - Destination stream. @param {number} sequence - Frame sequence. @param {Record<string, unknown>} value - Control body. */
|
|
767
|
+
function forwardControl(destination, type, streamId, sequence, value) {
|
|
768
|
+
forwardOutput(destination, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
|
|
769
|
+
}
|
|
770
|
+
|
|
461
771
|
/** @param {Bridge} bridge - Stream. @param {string} code - Reset code. @param {HubPhysical} [excluded] - Already-dead peer. */
|
|
462
772
|
function closeBridge(bridge, code, excluded) {
|
|
463
773
|
if (bridge.client !== excluded && !bridge.client.socket.destroyed) void sendControl(bridge.client, V3FrameType.RESET, bridge.clientStreamId, 0, {code})
|
|
@@ -465,13 +775,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
465
775
|
removeBridge(bridge)
|
|
466
776
|
}
|
|
467
777
|
|
|
468
|
-
/** @param {HubPhysical} physical - Connection. @param {string} code - Reason. */
|
|
469
|
-
function terminate(physical, code) {
|
|
778
|
+
/** @param {HubPhysical} physical - Connection. @param {string} code - Reason. @param {boolean} [retainStreams] - Preserve logical streams for paired resume. */
|
|
779
|
+
function terminate(physical, code, retainStreams = false) {
|
|
470
780
|
if (physical.closing) return
|
|
471
781
|
physical.closing = true
|
|
782
|
+
physical.retainOnDetach = retainStreams
|
|
783
|
+
physical.detachCode = code
|
|
472
784
|
const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
|
|
473
785
|
forceTimer.unref?.()
|
|
474
|
-
void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).finally(() => {
|
|
786
|
+
void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}, true).finally(() => {
|
|
475
787
|
clearTimeout(forceTimer)
|
|
476
788
|
physical.socket.destroy()
|
|
477
789
|
})
|
|
@@ -481,25 +793,117 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
481
793
|
function detachPhysical(physical, code) {
|
|
482
794
|
if (!physicals.delete(physical)) return
|
|
483
795
|
physical.closing = true
|
|
484
|
-
|
|
796
|
+
clearResumeTimeout(physical)
|
|
797
|
+
discardQueuedOutput(physical)
|
|
485
798
|
if (physical.gatewayId !== undefined) {
|
|
486
799
|
const owner = gateways.get(physical.gatewayId)
|
|
487
800
|
if (physical.role === "gateway" && owner?.physical === physical) {
|
|
488
801
|
owner.physical = undefined
|
|
489
|
-
|
|
490
|
-
|
|
802
|
+
if (!physical.retainOnDetach || closing) {
|
|
803
|
+
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, code, physical)
|
|
804
|
+
gateways.delete(owner.gatewayId)
|
|
805
|
+
} else {
|
|
806
|
+
scheduleOwnerExpiry(owner, "gateway")
|
|
807
|
+
const clientsToReconnect = new Set([...owner.streams.values()].map((bridge) => bridge.clientOwner.physical).filter((peer) => peer !== undefined))
|
|
808
|
+
for (const client of clientsToReconnect) terminate(/** @type {HubPhysical} */ (client), "GATEWAY_DISCONNECTED", true)
|
|
491
809
|
}
|
|
492
810
|
}
|
|
493
811
|
}
|
|
494
812
|
if (physical.role === "client" && physical.connectionId !== undefined) {
|
|
495
813
|
const owner = clients.get(physical.connectionId)
|
|
496
|
-
if (owner?.physical === physical)
|
|
814
|
+
if (owner?.physical === physical) {
|
|
815
|
+
owner.physical = undefined
|
|
816
|
+
if (!physical.retainOnDetach || closing) {
|
|
817
|
+
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, code, physical)
|
|
818
|
+
clients.delete(owner.connectionId)
|
|
819
|
+
} else {
|
|
820
|
+
scheduleOwnerExpiry(owner, "client")
|
|
821
|
+
const gatewaysToReconnect = new Set([...owner.streams.values()].map((bridge) => bridge.gatewayOwner.physical).filter((peer) => peer !== undefined))
|
|
822
|
+
for (const gateway of gatewaysToReconnect) terminate(/** @type {HubPhysical} */ (gateway), "CLIENT_DISCONNECTED", true)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** @param {GatewayOwner | ClientOwner} owner - Detached identity. @param {'gateway' | 'client'} role - Owner role. */
|
|
829
|
+
function scheduleOwnerExpiry(owner, role) {
|
|
830
|
+
if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
|
|
831
|
+
owner.expiryTimer = setTimeout(() => {
|
|
832
|
+
owner.expiryTimer = undefined
|
|
833
|
+
if (owner.physical !== undefined) return
|
|
834
|
+
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, "RESUME_WINDOW_EXPIRED")
|
|
835
|
+
if (role === "gateway") {
|
|
836
|
+
const gatewayOwner = /** @type {GatewayOwner} */ (owner)
|
|
837
|
+
if (gateways.get(gatewayOwner.gatewayId) === gatewayOwner) gateways.delete(gatewayOwner.gatewayId)
|
|
838
|
+
} else {
|
|
839
|
+
const clientOwner = /** @type {ClientOwner} */ (owner)
|
|
840
|
+
if (clients.get(clientOwner.connectionId) === clientOwner) clients.delete(clientOwner.connectionId)
|
|
841
|
+
}
|
|
842
|
+
void tryCompleteResumes()
|
|
843
|
+
}, resumeWindowMs)
|
|
844
|
+
owner.expiryTimer.unref?.()
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** @param {HubPhysical} physical - Destination. @param {Buffer} bytes - Encoded frame. @param {boolean} [allowClosing] - Allow a final GOAWAY after closing starts. @returns {Promise<boolean>} Delivery result. */
|
|
848
|
+
function enqueueOutput(physical, bytes, allowClosing = false) {
|
|
849
|
+
if (physical.socket.destroyed || (physical.closing && !allowClosing)) return Promise.resolve(false)
|
|
850
|
+
if (physical.outputBytes + bytes.length > maxOutputBytes) {
|
|
851
|
+
abortOutput(physical)
|
|
852
|
+
return Promise.resolve(false)
|
|
497
853
|
}
|
|
854
|
+
physical.outputBytes += bytes.length
|
|
855
|
+
const queued = new Promise((resolve) => physical.outputQueue.push({bytes, resolve}))
|
|
856
|
+
void drainOutput(physical)
|
|
857
|
+
return queued
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** @param {HubPhysical} physical - Destination output owner. */
|
|
861
|
+
async function drainOutput(physical) {
|
|
862
|
+
if (physical.outputWriting) return
|
|
863
|
+
physical.outputWriting = true
|
|
864
|
+
try {
|
|
865
|
+
while (physical.outputQueue.length > 0) {
|
|
866
|
+
const item = /** @type {HubOutputItem} */ (physical.outputQueue.shift())
|
|
867
|
+
let delivered = false
|
|
868
|
+
try {
|
|
869
|
+
delivered = await writeSocket(physical.socket, item.bytes)
|
|
870
|
+
} catch (error) {
|
|
871
|
+
physical.socket.destroy(asError(error))
|
|
872
|
+
}
|
|
873
|
+
physical.outputBytes = Math.max(0, physical.outputBytes - item.bytes.length)
|
|
874
|
+
item.resolve(delivered)
|
|
875
|
+
if (!delivered) {
|
|
876
|
+
discardQueuedOutput(physical)
|
|
877
|
+
abortOutput(physical, "DESTINATION_CLOSED")
|
|
878
|
+
break
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
} finally {
|
|
882
|
+
physical.outputWriting = false
|
|
883
|
+
if (physical.outputQueue.length > 0) void drainOutput(physical)
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** @param {HubPhysical} physical - Destination whose pending output is no longer deliverable. */
|
|
888
|
+
function discardQueuedOutput(physical) {
|
|
889
|
+
for (const item of physical.outputQueue.splice(0)) {
|
|
890
|
+
physical.outputBytes = Math.max(0, physical.outputBytes - item.bytes.length)
|
|
891
|
+
item.resolve(false)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** @param {HubPhysical} physical - Destination whose output transport failed. @param {string} [code] - Terminal code. */
|
|
896
|
+
function abortOutput(physical, code = "OUTPUT_BACKPRESSURE_LIMIT") {
|
|
897
|
+
physical.closing = true
|
|
898
|
+
physical.retainOnDetach = false
|
|
899
|
+
physical.detachCode ??= code
|
|
900
|
+
discardQueuedOutput(physical)
|
|
901
|
+
if (!physical.socket.destroyed) physical.socket.destroy(protocolError(code))
|
|
498
902
|
}
|
|
499
903
|
|
|
500
|
-
/** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. */
|
|
501
|
-
|
|
502
|
-
return
|
|
904
|
+
/** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. @param {boolean} [allowClosing] - Allow a final GOAWAY. @returns {Promise<boolean>} Delivery result. */
|
|
905
|
+
function sendControl(physical, type, streamId, sequence, value, allowClosing = false) {
|
|
906
|
+
return enqueueOutput(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)), allowClosing)
|
|
503
907
|
}
|
|
504
908
|
|
|
505
909
|
await new Promise((resolve, reject) => {
|
|
@@ -517,9 +921,11 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
517
921
|
physicalConnections: tcpSockets.size,
|
|
518
922
|
gatewayPhysicalConnections: [...physicals].filter((physical) => physical.role === "gateway" && physical.gatewayId !== undefined).length,
|
|
519
923
|
clientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.authenticated).length,
|
|
924
|
+
routeClassClientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.routeClass !== undefined).length,
|
|
925
|
+
queuedOutputBytes: [...physicals].reduce((total, physical) => total + physical.outputBytes, 0),
|
|
520
926
|
retainedClientIdentities: clients.size,
|
|
521
927
|
registeredGateways: gateways.size,
|
|
522
|
-
healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined).length,
|
|
928
|
+
healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined && !owner.physical.socket.destroyed && !owner.physical.closing && !owner.physical.resumeRequired).length,
|
|
523
929
|
gatewayRegistrations,
|
|
524
930
|
rejectedRegistrations,
|
|
525
931
|
rejectedOpens,
|
|
@@ -539,6 +945,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
539
945
|
for (const {socket} of pendingTls.values()) socket.destroy()
|
|
540
946
|
for (const physical of [...physicals]) terminate(physical, "HUB_SHUTDOWN")
|
|
541
947
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
|
|
948
|
+
for (const owner of gateways.values()) if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
|
|
949
|
+
for (const owner of clients.values()) if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
|
|
542
950
|
gateways.clear()
|
|
543
951
|
clients.clear()
|
|
544
952
|
}
|
|
@@ -557,7 +965,11 @@ function validateOptions(options) {
|
|
|
557
965
|
if (typeof options.tls !== "object" || options.tls === null || typeof options.tls.certFile !== "string" || typeof options.tls.keyFile !== "string") throw new TypeError("Reverse gateway hub requires TLS certFile and keyFile")
|
|
558
966
|
if (!zstdAvailable()) throw new Error("Reverse gateway hub requires Node.js zstd support")
|
|
559
967
|
if (options.knownGatewayIds !== undefined && (!Array.isArray(options.knownGatewayIds) || options.knownGatewayIds.some((gatewayId) => typeof gatewayId !== "string" || !GATEWAY_ID.test(gatewayId)) || new Set(options.knownGatewayIds).size !== options.knownGatewayIds.length)) throw new TypeError("knownGatewayIds must contain unique valid gateway IDs")
|
|
968
|
+
const routeClassPolicyConfigured = options.knownRouteClasses !== undefined || options.authorizeClientRouteClass !== undefined || options.resolveOpenGateways !== undefined
|
|
969
|
+
if (routeClassPolicyConfigured && (!Array.isArray(options.knownRouteClasses) || options.knownRouteClasses.length === 0 || typeof options.authorizeClientRouteClass !== "function" || typeof options.resolveOpenGateways !== "function")) throw new TypeError("Route-class hub policy requires knownRouteClasses, authorizeClientRouteClass, and resolveOpenGateways")
|
|
970
|
+
if (options.knownRouteClasses !== undefined && (options.knownRouteClasses.some((routeClass) => typeof routeClass !== "string" || !ROUTE_CLASS.test(routeClass)) || new Set(options.knownRouteClasses).size !== options.knownRouteClasses.length)) throw new TypeError("knownRouteClasses must contain unique valid route classes")
|
|
560
971
|
const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
|
|
561
972
|
const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
|
|
973
|
+
positiveInteger(options.resumeWindowMs, "resumeWindowMs", DEFAULT_RESUME_MS)
|
|
562
974
|
if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
|
|
563
975
|
}
|