velocious 1.0.561 → 1.0.563

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 (44) hide show
  1. package/README.md +3 -1
  2. package/build/configuration-types.js +1 -0
  3. package/build/configuration.js +44 -3
  4. package/build/http-server/client/websocket-session.js +3 -3
  5. package/build/http-server/client-delivery-queue.js +168 -0
  6. package/build/http-server/server-client.js +9 -0
  7. package/build/http-server/websocket-events-host.js +42 -12
  8. package/build/http-server/worker-handler/in-process.js +53 -14
  9. package/build/http-server/worker-handler/index.js +88 -18
  10. package/build/http-server/worker-handler/worker-thread.js +2 -2
  11. package/build/src/configuration-types.d.ts +8 -0
  12. package/build/src/configuration-types.d.ts.map +1 -1
  13. package/build/src/configuration-types.js +2 -1
  14. package/build/src/configuration.d.ts +34 -1
  15. package/build/src/configuration.d.ts.map +1 -1
  16. package/build/src/configuration.js +40 -4
  17. package/build/src/http-server/client/websocket-session.js +4 -4
  18. package/build/src/http-server/client-delivery-queue.d.ts +121 -0
  19. package/build/src/http-server/client-delivery-queue.d.ts.map +1 -0
  20. package/build/src/http-server/client-delivery-queue.js +152 -0
  21. package/build/src/http-server/server-client.d.ts +6 -0
  22. package/build/src/http-server/server-client.d.ts.map +1 -1
  23. package/build/src/http-server/server-client.js +10 -1
  24. package/build/src/http-server/websocket-events-host.d.ts +15 -4
  25. package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
  26. package/build/src/http-server/websocket-events-host.js +39 -13
  27. package/build/src/http-server/worker-handler/in-process.d.ts +19 -1
  28. package/build/src/http-server/worker-handler/in-process.d.ts.map +1 -1
  29. package/build/src/http-server/worker-handler/in-process.js +50 -14
  30. package/build/src/http-server/worker-handler/index.d.ts +36 -4
  31. package/build/src/http-server/worker-handler/index.d.ts.map +1 -1
  32. package/build/src/http-server/worker-handler/index.js +82 -18
  33. package/build/src/http-server/worker-handler/worker-thread.js +3 -3
  34. package/build/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +4 -3
  36. package/src/configuration-types.js +1 -0
  37. package/src/configuration.js +44 -3
  38. package/src/http-server/client/websocket-session.js +3 -3
  39. package/src/http-server/client-delivery-queue.js +168 -0
  40. package/src/http-server/server-client.js +9 -0
  41. package/src/http-server/websocket-events-host.js +42 -12
  42. package/src/http-server/worker-handler/in-process.js +53 -14
  43. package/src/http-server/worker-handler/index.js +88 -18
  44. package/src/http-server/worker-handler/worker-thread.js +2 -2
package/README.md CHANGED
@@ -1568,7 +1568,9 @@ database: {
1568
1568
 
1569
1569
  Velocious includes a lightweight websocket entry point for API-style calls and server-side events.
1570
1570
 
1571
- Inbound frames remain ordered when TCP splits a frame across reads. Velocious limits a single final client data frame and a reassembled fragmented message to 16 MiB; larger payloads close the connection. See [WebSocket connections](docs/websocket-connections.md) for wire-protocol details.
1571
+ Inbound frames remain ordered when TCP splits a frame across reads. Velocious limits a single final client data frame and a reassembled fragmented message to 16 MiB; larger payloads close the connection.
1572
+
1573
+ Server-to-client WebSocket delivery is bounded independently per client. The defaults retain at most 256 queued/in-flight completed frames or 16 MiB of serialized frame output; the HTTP 101 upgrade response and ordinary HTTP/file output remain outside that budget, while exact FIFO is preserved. A slow client that exceeds either limit is reported through the framework error events and deterministically closed without affecting other clients. V2 channel broadcast delivery and persistence remain isolated to the originating configuration when several applications share a process. Configure positive safe integers with `httpServer.websocketOutboundQueue.maxPendingFrames` and `maxPendingBytes`. See [WebSocket connections](docs/websocket-connections.md) for configuration and wire-protocol details.
1572
1574
 
1573
1575
  ## Connect and call a controller
1574
1576
 
@@ -255,6 +255,7 @@
255
255
  * @property {boolean} [inProcess] - Run HTTP handlers in the main thread instead of worker threads.
256
256
  * @property {number} [maxWorkers] - Backward-compatible alias for workers.
257
257
  * @property {number} [port] - Port to bind the HTTP server to.
258
+ * @property {{maxPendingBytes?: number, maxPendingFrames?: number}} [websocketOutboundQueue] - Per-client retained outbound WebSocket frame limits.
258
259
  * @property {number} [workers] - Worker handlers to start for the HTTP server.
259
260
  */
260
261
 
@@ -114,6 +114,25 @@ function resolveBeaconUnreachableReportMs(value) {
114
114
  return 30_000
115
115
  }
116
116
 
117
+ const DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_BYTES = 16 * 1024 * 1024
118
+ const DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_FRAMES = 256
119
+
120
+ /**
121
+ * Validates a positive safe integer configuration value.
122
+ * @param {?} value - Configured positive safe integer.
123
+ * @param {string} name - Configuration key.
124
+ * @param {number} defaultValue - Default value.
125
+ * @returns {number} - Validated configured or default value.
126
+ */
127
+ function positiveSafeInteger(value, name, defaultValue) {
128
+ if (value === undefined) return defaultValue
129
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
130
+ throw new TypeError(`${name} must be a positive safe integer`)
131
+ }
132
+
133
+ return value
134
+ }
135
+
117
136
  export default class VelociousConfiguration {
118
137
  /**
119
138
  * Close database connections promise.
@@ -209,7 +228,15 @@ export default class VelociousConfiguration {
209
228
  * @type {Promise<void> | undefined}
210
229
  */
211
230
  this._initializePromise = undefined
212
- this.httpServer = httpServer || {}
231
+ const websocketOutboundQueue = httpServer?.websocketOutboundQueue
232
+
233
+ this.httpServer = {
234
+ ...(httpServer || {}),
235
+ websocketOutboundQueue: {
236
+ maxPendingBytes: positiveSafeInteger(websocketOutboundQueue?.maxPendingBytes, "httpServer.websocketOutboundQueue.maxPendingBytes", DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_BYTES),
237
+ maxPendingFrames: positiveSafeInteger(websocketOutboundQueue?.maxPendingFrames, "httpServer.websocketOutboundQueue.maxPendingFrames", DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_FRAMES)
238
+ }
239
+ }
213
240
  /**
214
241
  * Stores the http server instance value.
215
242
  * @type {{getDebugSnapshot: () => Promise<Record<string, ?>>} | undefined} */
@@ -1693,7 +1720,8 @@ export default class VelociousConfiguration {
1693
1720
  websocketEvents.broadcastV2({
1694
1721
  channel: message.channel,
1695
1722
  broadcastParams: message.broadcastParams,
1696
- body: message.body
1723
+ body: message.body,
1724
+ configuration: this
1697
1725
  })
1698
1726
  return
1699
1727
  }
@@ -2342,6 +2370,19 @@ export default class VelociousConfiguration {
2342
2370
  */
2343
2371
  getWebsocketSessionHeartbeatSeconds() { return this._websocketSessionHeartbeatSeconds }
2344
2372
 
2373
+ /**
2374
+ * Gets per-client WebSocket outbound queue limits.
2375
+ * @returns {{maxBytes: number, maxFrames: number}} - Per-client outbound queue high-water marks.
2376
+ */
2377
+ getWebsocketOutboundQueueLimits() {
2378
+ const queue = this.httpServer.websocketOutboundQueue
2379
+
2380
+ return {
2381
+ maxBytes: queue.maxPendingBytes,
2382
+ maxFrames: queue.maxPendingFrames
2383
+ }
2384
+ }
2385
+
2345
2386
  /**
2346
2387
  * Registers a wrapper invoked around every WS-borne request /
2347
2388
  * connection message / channel dispatch. The wrapper receives the
@@ -2537,7 +2578,7 @@ export default class VelociousConfiguration {
2537
2578
  const websocketEvents = this._websocketEvents
2538
2579
 
2539
2580
  if (websocketEvents && typeof websocketEvents.broadcastV2 === "function") {
2540
- websocketEvents.broadcastV2({channel: name, broadcastParams, body})
2581
+ websocketEvents.broadcastV2({channel: name, broadcastParams, body, configuration: this})
2541
2582
  return
2542
2583
  }
2543
2584
 
@@ -423,7 +423,7 @@ export default class VelociousHttpServerClientWebsocketSession {
423
423
  sendGoodbye(client) {
424
424
  const frame = Buffer.from([WEBSOCKET_FINAL_FRAME | WEBSOCKET_OPCODE_CLOSE, 0x00])
425
425
 
426
- client.events.emit("output", frame)
426
+ client.events.emit("output", frame, {websocketFrame: true})
427
427
  }
428
428
 
429
429
  /**
@@ -967,7 +967,7 @@ export default class VelociousHttpServerClientWebsocketSession {
967
967
  header[0] = WEBSOCKET_FINAL_FRAME | opcode
968
968
  header[1] = payload.length
969
969
 
970
- this.client.events.emit("output", Buffer.concat([header, payload]))
970
+ this.client.events.emit("output", Buffer.concat([header, payload]), {websocketFrame: true})
971
971
  }
972
972
 
973
973
  /**
@@ -1012,7 +1012,7 @@ export default class VelociousHttpServerClientWebsocketSession {
1012
1012
 
1013
1013
  header[0] = WEBSOCKET_FINAL_FRAME | WEBSOCKET_OPCODE_TEXT
1014
1014
 
1015
- this.client.events.emit("output", Buffer.concat([header, payload]))
1015
+ this.client.events.emit("output", Buffer.concat([header, payload]), {websocketFrame: true})
1016
1016
  }
1017
1017
 
1018
1018
  /**
@@ -0,0 +1,168 @@
1
+ // @ts-check
2
+
3
+ export class ClientDeliveryQueueOverflowError extends Error {
4
+ /**
5
+ * Builds an outbound queue overflow error.
6
+ * @param {object} args - Overflow details.
7
+ * @param {number} args.clientCount - Client identifier.
8
+ * @param {number} args.maxBytes - Configured byte high-water mark.
9
+ * @param {number} args.maxFrames - Configured frame high-water mark.
10
+ * @param {number} args.pendingBytes - Bytes retained before rejecting the frame.
11
+ * @param {number} args.pendingFrames - Frames retained before rejecting the frame.
12
+ * @param {number} args.rejectedBytes - Rejected frame size.
13
+ */
14
+ constructor({clientCount, maxBytes, maxFrames, pendingBytes, pendingFrames, rejectedBytes}) {
15
+ super(`WebSocket client ${clientCount} exceeded its outbound queue limit (${pendingFrames}/${maxFrames} frames, ${pendingBytes}/${maxBytes} bytes; rejected ${rejectedBytes} bytes)`)
16
+ this.name = "ClientDeliveryQueueOverflowError"
17
+ }
18
+ }
19
+
20
+ /**
21
+ * @typedef {object} DeliveryTask
22
+ * @property {number} byteLength - Retained complete-buffer bytes.
23
+ * @property {boolean} countedFrame - Whether this task is an outbound frame.
24
+ * @property {() => Promise<void>} delivery - Delivery operation.
25
+ * @property {(error?: Error) => void} settle - Settles the enqueue promise.
26
+ */
27
+
28
+ export default class ClientDeliveryQueue {
29
+ /**
30
+ * Builds a per-client delivery queue.
31
+ * @param {object} args - Queue options.
32
+ * @param {number} args.clientCount - Client identifier.
33
+ * @param {number} args.maxBytes - Byte high-water mark.
34
+ * @param {number} args.maxFrames - Frame high-water mark.
35
+ * @param {(error: ClientDeliveryQueueOverflowError) => void} args.onOverflow - Overflow handler.
36
+ */
37
+ constructor({clientCount, maxBytes, maxFrames, onOverflow}) {
38
+ this.clientCount = clientCount
39
+ this.maxBytes = maxBytes
40
+ this.maxFrames = maxFrames
41
+ this.onOverflow = onOverflow
42
+ /** @type {DeliveryTask[]} */
43
+ this.tasks = []
44
+ /** @type {DeliveryTask | undefined} */
45
+ this.activeTask = undefined
46
+ this.pendingBytes = 0
47
+ this.pendingFrames = 0
48
+ this.destroyed = false
49
+ }
50
+
51
+ /**
52
+ * Enqueues one complete output buffer.
53
+ * @param {object} args - Delivery details.
54
+ * @param {number} args.byteLength - Exact buffer byte length.
55
+ * @param {() => Promise<void>} args.delivery - Delivery operation.
56
+ * @returns {Promise<void>} - Settles after delivery or teardown.
57
+ */
58
+ enqueueFrame({byteLength, delivery}) {
59
+ if (this.destroyed) return Promise.resolve()
60
+
61
+ if (this.pendingFrames + 1 > this.maxFrames || this.pendingBytes + byteLength > this.maxBytes) {
62
+ const error = new ClientDeliveryQueueOverflowError({
63
+ clientCount: this.clientCount,
64
+ maxBytes: this.maxBytes,
65
+ maxFrames: this.maxFrames,
66
+ pendingBytes: this.pendingBytes,
67
+ pendingFrames: this.pendingFrames,
68
+ rejectedBytes: byteLength
69
+ })
70
+
71
+ this.onOverflow(error)
72
+ return Promise.reject(error)
73
+ }
74
+
75
+ this.pendingFrames += 1
76
+ this.pendingBytes += byteLength
77
+ return this._enqueue({byteLength, countedFrame: true, delivery})
78
+ }
79
+
80
+ /**
81
+ * Enqueues an ordering-only operation that retains no complete output frame.
82
+ * @param {() => Promise<void>} delivery - Delivery operation.
83
+ * @returns {Promise<void>} - Settles after delivery or teardown.
84
+ */
85
+ enqueueControl(delivery) {
86
+ if (this.destroyed) return Promise.resolve()
87
+
88
+ return this._enqueue({byteLength: 0, countedFrame: false, delivery})
89
+ }
90
+
91
+ /**
92
+ * Releases queued and active accounting during explicit client teardown.
93
+ * @returns {void}
94
+ */
95
+ destroy() {
96
+ if (this.destroyed) return
97
+
98
+ this.destroyed = true
99
+ const tasks = this.activeTask ? [this.activeTask, ...this.tasks] : this.tasks
100
+
101
+ this.activeTask = undefined
102
+ this.tasks = []
103
+ this.pendingBytes = 0
104
+ this.pendingFrames = 0
105
+
106
+ for (const task of tasks) task.settle()
107
+ }
108
+
109
+ /**
110
+ * Gets current retained-buffer accounting.
111
+ * @returns {{pendingBytes: number, pendingFrames: number}} - Current retained-buffer accounting.
112
+ */
113
+ snapshot() {
114
+ return {pendingBytes: this.pendingBytes, pendingFrames: this.pendingFrames}
115
+ }
116
+
117
+ /**
118
+ * Enqueues a delivery task.
119
+ * @param {Omit<DeliveryTask, "settle">} task - Task to enqueue.
120
+ * @returns {Promise<void>} - Task completion.
121
+ */
122
+ _enqueue(task) {
123
+ const promise = new Promise((resolve, reject) => {
124
+ this.tasks.push({
125
+ ...task,
126
+ settle: (error) => error ? reject(error) : resolve(undefined)
127
+ })
128
+ })
129
+
130
+ this._drain()
131
+ return promise
132
+ }
133
+
134
+ /**
135
+ * Starts the next task when idle.
136
+ * @returns {void} - No return value.
137
+ */
138
+ _drain() {
139
+ if (this.destroyed || this.activeTask) return
140
+
141
+ const task = this.tasks.shift()
142
+ if (!task) return
143
+
144
+ this.activeTask = task
145
+ void task.delivery().then(
146
+ () => this._finish(task),
147
+ (error) => this._finish(task, error)
148
+ )
149
+ }
150
+
151
+ /**
152
+ * Finishes the active delivery task.
153
+ * @param {DeliveryTask} task - Completed task.
154
+ * @param {Error} [error] - Delivery error.
155
+ * @returns {void}
156
+ */
157
+ _finish(task, error) {
158
+ if (this.destroyed || this.activeTask !== task) return
159
+
160
+ this.activeTask = undefined
161
+ if (task.countedFrame) {
162
+ this.pendingBytes -= task.byteLength
163
+ this.pendingFrames -= 1
164
+ }
165
+ task.settle(error)
166
+ this._drain()
167
+ }
168
+ }
@@ -76,6 +76,15 @@ export default class ServerClient {
76
76
  })
77
77
  }
78
78
 
79
+ /**
80
+ * Immediately destroys the socket and all transport-owned write buffers.
81
+ * @param {Error} error - Destruction reason.
82
+ * @returns {void}
83
+ */
84
+ destroy(error) {
85
+ if (!this.socket.destroyed) this.socket.destroy(error)
86
+ }
87
+
79
88
  /**
80
89
  * On socket data.
81
90
  * @param {Buffer} chunk - Chunk.
@@ -8,6 +8,10 @@ export class VelociousHttpServerWebsocketEventsHost {
8
8
  * Narrows the runtime value to the documented type.
9
9
  * @type {Set<import("./worker-handler/index.js").default>} */
10
10
  this.handlers = new Set()
11
+ /**
12
+ * Broadcast handlers grouped by the configuration that owns them.
13
+ * @type {Map<import("../configuration.js").default, Set<import("./worker-handler/index.js").default>>} */
14
+ this.broadcastHandlersByConfiguration = new Map()
11
15
  this.publishQueue = Promise.resolve()
12
16
  }
13
17
 
@@ -30,8 +34,23 @@ export class VelociousHttpServerWebsocketEventsHost {
30
34
  */
31
35
  register(handler) {
32
36
  this.handlers.add(handler)
37
+ let configurationHandlers = this.broadcastHandlersByConfiguration.get(handler.configuration)
38
+
39
+ if (!configurationHandlers) {
40
+ configurationHandlers = new Set()
41
+ this.broadcastHandlersByConfiguration.set(handler.configuration, configurationHandlers)
42
+ }
43
+
44
+ configurationHandlers.add(handler)
45
+
46
+ return () => {
47
+ this.handlers.delete(handler)
48
+ configurationHandlers.delete(handler)
33
49
 
34
- return () => this.handlers.delete(handler)
50
+ if (configurationHandlers.size === 0) {
51
+ this.broadcastHandlersByConfiguration.delete(handler.configuration)
52
+ }
53
+ }
35
54
  }
36
55
 
37
56
  /**
@@ -69,17 +88,24 @@ export class VelociousHttpServerWebsocketEventsHost {
69
88
  * @param {string} args.channel - Channel name.
70
89
  * @param {Record<string, ?>} args.broadcastParams - Routing filter params.
71
90
  * @param {?} args.body - Message body.
91
+ * @param {import("../configuration.js").default} args.configuration - Originating configuration.
72
92
  * @returns {void}
73
93
  */
74
- broadcastV2({body, broadcastParams, channel}) {
94
+ broadcastV2({body, broadcastParams, channel, configuration}) {
75
95
  // Chain onto publishQueue so persistence completes before
76
96
  // the next broadcast — without this, a subscriber that connects
77
97
  // immediately after a broadcast could miss the just-persisted
78
98
  // event when replaying from lastEventId on a slow DB.
79
99
  this._queuePublish(async () => {
80
- const persistedEvent = await this._persistV2EventIfNeeded({body, channel})
100
+ const persistedEvent = await this._persistV2EventIfNeeded({body, channel, configuration})
101
+ const dispatchedTargets = new Set()
81
102
 
82
- for (const handler of this.handlers) {
103
+ for (const handler of this.broadcastHandlersByConfiguration.get(configuration) || []) {
104
+ const dispatchKey = handler.websocketV2BroadcastDispatchKey()
105
+
106
+ if (dispatchedTargets.has(dispatchKey)) continue
107
+
108
+ dispatchedTargets.add(dispatchKey)
83
109
  handler.dispatchWebsocketV2Broadcast({
84
110
  body,
85
111
  broadcastParams,
@@ -88,18 +114,19 @@ export class VelociousHttpServerWebsocketEventsHost {
88
114
  createdAt: persistedEvent?.createdAt
89
115
  })
90
116
  }
91
- }, "Failed to persist/broadcast V2 event")
117
+ }, "Failed to persist/broadcast V2 event", configuration)
92
118
  }
93
119
 
94
120
  /**
95
121
  * Runs queue publish.
96
122
  * @param {() => Promise<void>} callback - Publish work to run in order.
97
123
  * @param {string} errorMessage - Message logged when publish work fails.
124
+ * @param {import("../configuration.js").default} [originatingConfiguration] - Configuration whose context owns the work.
98
125
  * @returns {void}
99
126
  */
100
- _queuePublish(callback, errorMessage) {
127
+ _queuePublish(callback, errorMessage, originatingConfiguration) {
101
128
  const handler = this.handlers.values().next().value
102
- const configuration = handler?.configuration
129
+ const configuration = originatingConfiguration || handler?.configuration
103
130
 
104
131
  this.publishQueue = this.publishQueue
105
132
  .then(async () => {
@@ -120,10 +147,11 @@ export class VelociousHttpServerWebsocketEventsHost {
120
147
  * @param {object} args - Options.
121
148
  * @param {?} args.body - Event body.
122
149
  * @param {string} args.channel - Channel name.
150
+ * @param {import("../configuration.js").default} args.configuration - Originating configuration.
123
151
  * @returns {Promise<{createdAt: string, id: string} | null>} - Persisted event metadata when storage is enabled.
124
152
  */
125
- async _persistV2EventIfNeeded({body, channel}) {
126
- return await this._persistChannelEventIfNeeded({channel, payload: body})
153
+ async _persistV2EventIfNeeded({body, channel, configuration}) {
154
+ return await this._persistChannelEventIfNeeded({channel, payload: body, configuration})
127
155
  }
128
156
 
129
157
  /**
@@ -142,14 +170,16 @@ export class VelociousHttpServerWebsocketEventsHost {
142
170
  * @param {object} args - Options object.
143
171
  * @param {string} args.channel - Channel name.
144
172
  * @param {?} args.payload - Payload data.
173
+ * @param {import("../configuration.js").default} [args.configuration] - Configuration owning the event store.
145
174
  * @returns {Promise<{createdAt: string, id: string} | null>} - Persisted event metadata.
146
175
  */
147
- async _persistChannelEventIfNeeded({channel, payload}) {
176
+ async _persistChannelEventIfNeeded({channel, payload, configuration}) {
148
177
  const handler = this.handlers.values().next().value
178
+ const eventConfiguration = configuration || handler?.configuration
149
179
 
150
- if (!handler?.configuration) return null
180
+ if (!eventConfiguration) return null
151
181
 
152
- const websocketEventLogStore = websocketEventLogStoreForConfiguration(handler.configuration)
182
+ const websocketEventLogStore = websocketEventLogStoreForConfiguration(eventConfiguration)
153
183
  const shouldPersist = await websocketEventLogStore.shouldPersistChannel(channel)
154
184
 
155
185
  if (!shouldPersist) return null
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import Client from "../client/index.js"
4
+ import ClientDeliveryQueue from "../client-delivery-queue.js"
4
5
  import dispatchChannelSubscribers from "./channel-subscriber-dispatch.js"
5
6
  import Logger from "../../logger.js"
6
7
  import websocketEventsHost from "../websocket-events-host.js"
@@ -23,7 +24,7 @@ export default class VelociousHttpServerInProcessHandler {
23
24
 
24
25
  /**
25
26
  * Narrows the runtime value to the documented type.
26
- * @type {Record<number, {httpClient: Client, serverClient: import("../server-client.js").default}>} */
27
+ * @type {Record<number, {deliveryQueue: ClientDeliveryQueue, httpClient: Client, serverClient: import("../server-client.js").default}>} */
27
28
  this.clients = {}
28
29
 
29
30
  /** @type {Set<Promise<void>>} */
@@ -56,25 +57,36 @@ export default class VelociousHttpServerInProcessHandler {
56
57
  remoteAddress: serverClient.remoteAddress
57
58
  })
58
59
 
59
- let deliveryQueue = Promise.resolve()
60
- const enqueueDelivery = (/** @type {() => Promise<void>} */ delivery) => {
61
- deliveryQueue = deliveryQueue
62
- .catch(() => {})
63
- .then(delivery)
64
-
65
- return deliveryQueue
66
- }
60
+ const {maxBytes, maxFrames} = this.configuration.getWebsocketOutboundQueueLimits()
61
+ const deliveryQueue = new ClientDeliveryQueue({
62
+ clientCount,
63
+ maxBytes,
64
+ maxFrames,
65
+ onOverflow: (error) => {
66
+ deliveryQueue.destroy()
67
+ serverClient.destroy(error)
68
+ this._reportOutboundQueueOverflow({clientCount, error})
69
+ }
70
+ })
67
71
 
68
- httpClient.events.on("output", (output) => {
72
+ httpClient.events.on("output", (output, {websocketFrame = false} = {}) => {
69
73
  if (output !== null && output !== undefined) {
70
- void enqueueDelivery(() => serverClient.send(output)).catch((error) => {
74
+ const delivery = () => serverClient.send(output)
75
+ const queued = websocketFrame
76
+ ? deliveryQueue.enqueueFrame({
77
+ byteLength: typeof output === "string" ? Buffer.byteLength(output) : output.byteLength,
78
+ delivery
79
+ })
80
+ : deliveryQueue.enqueueControl(delivery)
81
+
82
+ void queued.catch((error) => {
71
83
  this.logger.error(() => ["Failed to deliver client output", {clientCount}, error])
72
84
  })
73
85
  }
74
86
  })
75
87
 
76
88
  httpClient.events.on("file", ({filePath, sendBody, settle}) => {
77
- void enqueueDelivery(async () => {
89
+ void deliveryQueue.enqueueControl(async () => {
78
90
  await settle(await serverClient.sendFile(filePath, sendBody))
79
91
  }).catch((error) => {
80
92
  this.logger.error(() => ["Failed to deliver file response", {clientCount, filePath}, error])
@@ -83,11 +95,12 @@ export default class VelociousHttpServerInProcessHandler {
83
95
  })
84
96
 
85
97
  httpClient.events.on("close", () => {
86
- void enqueueDelivery(() => serverClient.end())
98
+ void deliveryQueue.enqueueControl(() => serverClient.end())
87
99
  .finally(() => delete this.clients[clientCount])
88
100
  })
89
101
 
90
102
  serverClient.events.on("close", () => {
103
+ deliveryQueue.destroy()
91
104
  const cleanup = httpClient.abortPendingFileResponses()
92
105
  .catch((error) => {
93
106
  this.logger.warn("Failed to abort file responses after client close", error)
@@ -100,7 +113,7 @@ export default class VelociousHttpServerInProcessHandler {
100
113
  this.pendingClientCloseCleanups.add(cleanup)
101
114
  })
102
115
 
103
- this.clients[clientCount] = {httpClient, serverClient}
116
+ this.clients[clientCount] = {deliveryQueue, httpClient, serverClient}
104
117
 
105
118
  // Create a message-port shim so ServerClient.onSocketData can route data
106
119
  // to the in-process HTTP Client without needing a real worker thread.
@@ -118,6 +131,24 @@ export default class VelociousHttpServerInProcessHandler {
118
131
  serverClient.listen()
119
132
  }
120
133
 
134
+ /**
135
+ * Reports a per-client outbound queue overflow.
136
+ * @param {object} args - Overflow details.
137
+ * @param {number} args.clientCount - Affected client.
138
+ * @param {Error} args.error - Overflow error.
139
+ * @returns {void}
140
+ */
141
+ _reportOutboundQueueOverflow({clientCount, error}) {
142
+ const errorPayload = {
143
+ context: {clientCount, websocketOutboundQueueOverflow: true, workerCount: this.workerCount},
144
+ error
145
+ }
146
+ const errorEvents = this.configuration.getErrorEvents()
147
+
148
+ errorEvents.emit("framework-error", errorPayload)
149
+ errorEvents.emit("all-error", {...errorPayload, errorType: "framework-error"})
150
+ }
151
+
121
152
  /**
122
153
  * Runs stop.
123
154
  * @returns {Promise<void>} */
@@ -158,6 +189,14 @@ export default class VelociousHttpServerInProcessHandler {
158
189
  return this.configuration._broadcastToChannelLocal(channel, broadcastParams, body, {eventId})
159
190
  }
160
191
 
192
+ /**
193
+ * Gets the configuration-wide V2 broadcast target shared by in-process handlers.
194
+ * @returns {import("../../configuration.js").default} - Shared configuration target.
195
+ */
196
+ websocketV2BroadcastDispatchKey() {
197
+ return this.configuration
198
+ }
199
+
161
200
  /**
162
201
  * Runs dispatch websocket event.
163
202
  * @param {object} args - Options object.