velocious 1.0.565 → 1.0.567

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.
@@ -1,18 +1,36 @@
1
1
  // @ts-check
2
2
 
3
- import {randomUUID} from "node:crypto"
3
+ import { randomUUID } from "node:crypto"
4
+ import { ensureError } from "typanic"
4
5
 
5
- import EventEmitter from "../../utils/event-emitter.js"
6
+ import { ValidationError } from "../../database/record/index.js"
6
7
  import Logger from "../../logger.js"
8
+ import EventEmitter from "../../utils/event-emitter.js"
9
+ import isPlainObject from "../../utils/plain-object.js"
10
+ import VelociousError from "../../velocious-error.js"
11
+ import WebsocketChannel from "../websocket-channel.js"
12
+ import { websocketEventLogStoreForConfiguration } from "../websocket-event-log-store.js"
7
13
  import RequestRunner from "./request-runner.js"
8
14
  import WebsocketRequest from "./websocket-request.js"
9
- import WebsocketChannel from "../websocket-channel.js"
10
- import {websocketEventLogStoreForConfiguration} from "../websocket-event-log-store.js"
11
15
 
12
16
  /**
13
17
  * Defines this typedef.
14
18
  * @typedef {{type: "subscribe", channel: string, lastEventId?: string, params?: Record<string, ?>} | {type: "metadata", data?: Record<string, ?>} | {type?: "request", body?: ?, headers?: Record<string, ?>, id?: string | number | null, method: string, path: string} | Record<string, ?>} WebsocketSessionMessage
15
19
  */
20
+
21
+ /**
22
+ * @typedef {object} InboundMessageAdmission
23
+ * @property {number} byteLength - Exact raw text payload bytes charged to this admission.
24
+ * @property {number} generation - Accounting generation active when admitted.
25
+ * @property {boolean} released - Whether this admission has already been released.
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} InboundMessageWork
30
+ * @property {InboundMessageAdmission} admission - Admission ownership.
31
+ * @property {WebsocketSessionMessage} message - Decoded client message.
32
+ */
33
+
16
34
  const WEBSOCKET_FINAL_FRAME = 0x80
17
35
  const WEBSOCKET_OPCODE_CONTINUATION = 0x0
18
36
  const WEBSOCKET_OPCODE_TEXT = 0x1
@@ -21,6 +39,10 @@ const WEBSOCKET_OPCODE_CLOSE = 0x8
21
39
  const WEBSOCKET_OPCODE_PING = 0x9
22
40
  const WEBSOCKET_OPCODE_PONG = 0xA
23
41
 
42
+ const WEBSOCKET_CLOSE_POLICY_VIOLATION = 1008
43
+ const WEBSOCKET_INBOUND_BACKLOG_CLOSE_REASON = "Inbound message backlog exceeded"
44
+ const WEBSOCKET_MAX_CLOSE_REASON_BYTES = 123
45
+
24
46
  /** Cap on the paused outbound queue; oldest frames drop on overflow. */
25
47
  const WEBSOCKET_PAUSED_QUEUE_CAP = 1000
26
48
 
@@ -88,7 +110,7 @@ export default class VelociousHttpServerClientWebsocketSession {
88
110
  channelReplayStates = new Map()
89
111
  /**
90
112
  * Message queue.
91
- * @type {WebsocketSessionMessage[]} */
113
+ * @type {InboundMessageWork[]} */
92
114
  messageQueue = []
93
115
 
94
116
  /**
@@ -114,6 +136,15 @@ export default class VelociousHttpServerClientWebsocketSession {
114
136
  this.messageHandlerPromise = messageHandlerPromise
115
137
  this.pendingMessageHandler = Boolean(messageHandlerPromise)
116
138
  this.logger = new Logger(this)
139
+ const inboundQueueLimits = this.configuration.getWebsocketInboundQueueLimits()
140
+
141
+ this._inboundMaxPendingBytes = inboundQueueLimits.maxBytes
142
+ this._inboundMaxPendingMessages = inboundQueueLimits.maxMessages
143
+ this._inboundPendingBytes = 0
144
+ this._inboundPendingMessages = 0
145
+ this._inboundAccountingGeneration = 0
146
+ this._inboundClosed = false
147
+ this._inboundBacklogOverloaded = false
117
148
 
118
149
  /**
119
150
  * Narrows the runtime value to the documented type.
@@ -282,6 +313,9 @@ export default class VelociousHttpServerClientWebsocketSession {
282
313
 
283
314
  destroy() {
284
315
  this._stopHeartbeat()
316
+ this._resetFragmentBuffer()
317
+ this._clearBufferedFrameChunks()
318
+ this._abandonInboundMessages()
285
319
  this.configuration._websocketSessions.delete(this)
286
320
  this._paused = false
287
321
  void this._teardownChannel()
@@ -310,7 +344,7 @@ export default class VelociousHttpServerClientWebsocketSession {
310
344
  // socket is alive. Mark it here, before `_processBuffer` may return
311
345
  // early waiting for the rest of an incomplete frame.
312
346
  this._heartbeatAlive = true
313
- if (data.length === 0) return
347
+ if (this._inboundClosed || data.length === 0) return
314
348
 
315
349
  this._bufferChunks.push(data)
316
350
  this._bufferedBytes += data.length
@@ -409,7 +443,11 @@ export default class VelociousHttpServerClientWebsocketSession {
409
443
  }
410
444
 
411
445
  await this._registerChannel(channel, tenant)
412
- } catch (error) {
446
+ } catch (caughtError) {
447
+ const error = this._reportUnexpectedDispatchError(caughtError, {
448
+ stage: "websocket-channel-initialize"
449
+ })
450
+
413
451
  this.logger.error(() => ["Failed to initialize websocket channel", error])
414
452
  }
415
453
  }
@@ -417,31 +455,126 @@ export default class VelociousHttpServerClientWebsocketSession {
417
455
  /**
418
456
  * Runs send goodbye.
419
457
  * @param {import("./index.js").default} client - Client instance.
458
+ * @param {{code?: number, reason?: string}} [options] - Optional close status.
420
459
  * @returns {void} - No return value.
421
460
  */
422
- sendGoodbye(client) {
423
- const frame = Buffer.from([WEBSOCKET_FINAL_FRAME | WEBSOCKET_OPCODE_CLOSE, 0x00])
461
+ sendGoodbye(client, {code, reason = ""} = {}) {
462
+ let payload
463
+
464
+ if (code === undefined) {
465
+ payload = Buffer.alloc(0)
466
+ } else {
467
+ const reasonBytes = Buffer.from(reason, "utf-8")
468
+
469
+ if (reasonBytes.length > WEBSOCKET_MAX_CLOSE_REASON_BYTES) {
470
+ throw new RangeError("WebSocket close reason must not exceed 123 UTF-8 bytes")
471
+ }
472
+
473
+ payload = Buffer.allocUnsafe(2 + reasonBytes.length)
474
+ payload.writeUInt16BE(code, 0)
475
+ reasonBytes.copy(payload, 2)
476
+ }
477
+
478
+ const frame = Buffer.concat([
479
+ Buffer.from([WEBSOCKET_FINAL_FRAME | WEBSOCKET_OPCODE_CLOSE, payload.length]),
480
+ payload
481
+ ])
424
482
 
425
483
  client.events.emit("output", frame, {websocketFrame: true})
426
484
  }
427
485
 
486
+ /**
487
+ * Whether a caught dispatch error is an expected client-flow failure.
488
+ * @param {Error} error - Normalized dispatch error.
489
+ * @returns {boolean} - Whether framework error reporters should ignore it.
490
+ */
491
+ _expectedClientError(error) {
492
+ if (error instanceof ValidationError) return true
493
+ if (error instanceof VelociousError && error.safeToExpose) return true
494
+
495
+ const annotatedError = /** @type {Error & {errorType?: string, velocious?: Record<string, ?>}} */ (error)
496
+
497
+ if (isPlainObject(annotatedError.velocious)) return true
498
+
499
+ return typeof annotatedError.errorType === "string" && annotatedError.errorType.length > 0
500
+ }
501
+
502
+ /**
503
+ * Reports one unexpected WebSocket dispatch failure and returns its normalized Error.
504
+ * @param {?} caughtError - Caught dispatch failure.
505
+ * @param {Record<string, ?>} context - Structured dispatch context.
506
+ * @returns {Error} - Normalized error for existing logs and client responses.
507
+ */
508
+ _reportUnexpectedDispatchError(caughtError, context) {
509
+ const error = ensureError(caughtError)
510
+
511
+ if (this._expectedClientError(error)) return error
512
+
513
+ const errorPayload = {
514
+ context,
515
+ error,
516
+ request: this.upgradeRequest
517
+ }
518
+ const errorEvents = this.configuration.getErrorEvents()
519
+
520
+ errorEvents.emit("framework-error", errorPayload)
521
+ errorEvents.emit("all-error", {...errorPayload, errorType: "framework-error"})
522
+
523
+ return error
524
+ }
525
+
428
526
  /**
429
527
  * Runs handle message.
430
528
  * @param {WebsocketSessionMessage} message - Message text.
431
529
  * @returns {Promise<void>} - Resolves when complete.
432
530
  */
433
531
  async _handleMessage(message) {
532
+ const admission = this._admitInboundMessage(0)
533
+
534
+ if (!admission) return
535
+ await this._handleMessageWork({admission, message})
536
+ }
537
+
538
+ /**
539
+ * Appends an admitted message to the per-session FIFO chain.
540
+ * @param {InboundMessageWork} work - Admitted decoded message.
541
+ * @returns {Promise<void>} - Resolves when complete.
542
+ */
543
+ async _handleMessageWork(work) {
434
544
  // Serialize per-session: chain onto `_messageChain` so messages
435
545
  // are processed one at a time. Without this, fire-and-forget
436
546
  // dispatch from `_processBuffer` lets message B read
437
547
  // `session.data` before A has finished writing it.
438
548
  const previous = this._messageChain
439
- const next = previous.then(() => this._dispatchMessage(message))
549
+ const next = previous.then(() => this._runMessageWork(work))
440
550
 
441
551
  this._messageChain = next.catch(() => {})
442
552
  await next
443
553
  }
444
554
 
555
+ /**
556
+ * Dispatches or transfers one admitted message while retaining its accounting.
557
+ * @param {InboundMessageWork} work - Admitted decoded message.
558
+ * @returns {Promise<void>} - Resolves after dispatch or resolver-queue transfer.
559
+ */
560
+ async _runMessageWork(work) {
561
+ if (this._inboundClosed) {
562
+ this._releaseInboundAdmission(work.admission)
563
+ return
564
+ }
565
+
566
+ if (this.pendingMessageHandler) {
567
+ this.messageQueue.push(work)
568
+ return
569
+ }
570
+
571
+ try {
572
+ await this._dispatchMessage(work.message)
573
+ } finally {
574
+ this._releaseInboundAdmission(work.admission)
575
+ }
576
+ }
577
+
445
578
  /**
446
579
  * Runs dispatch message.
447
580
  * @param {WebsocketSessionMessage} message - Message text.
@@ -466,11 +599,6 @@ export default class VelociousHttpServerClientWebsocketSession {
466
599
  * @returns {Promise<void>}
467
600
  */
468
601
  async _handleMessageInner(message) {
469
- if (this.pendingMessageHandler) {
470
- this.messageQueue.push(message)
471
- return
472
- }
473
-
474
602
  // The messageHandler short-circuits default routing only when the
475
603
  // app actually declared an `onMessage` hook. Apps that only want
476
604
  // session-lifecycle tracking (`onOpen`/`onClose`) still need the
@@ -486,7 +614,7 @@ export default class VelociousHttpServerClientWebsocketSession {
486
614
  if (subscribePayload) {
487
615
  const {channel, lastEventId, params} = subscribePayload
488
616
 
489
- if (!channel) throw new Error("channel is required for subscribe")
617
+ if (!channel) throw VelociousError.safe("channel is required for subscribe")
490
618
  const resolver = this.configuration.getWebsocketChannelResolver?.()
491
619
 
492
620
  if (resolver) {
@@ -558,8 +686,8 @@ export default class VelociousHttpServerClientWebsocketSession {
558
686
 
559
687
  const {body, headers, id, method, path} = requestPayload
560
688
 
561
- if (!method) throw new Error("method is required")
562
- if (!path) throw new Error("path is required")
689
+ if (!method) throw VelociousError.safe("method is required")
690
+ if (!path) throw VelociousError.safe("path is required")
563
691
 
564
692
  const request = new WebsocketRequest({
565
693
  body,
@@ -736,14 +864,27 @@ export default class VelociousHttpServerClientWebsocketSession {
736
864
  continue
737
865
  }
738
866
 
867
+ const admission = this._admitInboundMessage(finalPayload.length)
868
+
869
+ if (!admission) return
870
+
739
871
  try {
740
872
  const message = JSON.parse(finalPayload.toString("utf-8"))
741
873
 
742
- this._handleMessage(message).catch((error) => {
874
+ this._handleMessageWork({admission, message}).catch((caughtError) => {
875
+ const clientErrorMessage = caughtError instanceof Error ? caughtError.message : String(caughtError)
876
+ const error = this._reportUnexpectedDispatchError(caughtError, {
877
+ stage: "websocket-message-dispatch"
878
+ })
879
+
743
880
  this.logger.error(() => ["Websocket message handler failed", error])
744
- this.sendJson({error: error.message, type: "error"})
881
+ this.sendJson({
882
+ error: clientErrorMessage,
883
+ type: "error"
884
+ })
745
885
  })
746
886
  } catch (error) {
887
+ this._releaseInboundAdmission(admission)
747
888
  this.logger.error(() => ["Failed to parse websocket message", error])
748
889
  this.sendJson({error: "Invalid websocket message", type: "error"})
749
890
  }
@@ -830,6 +971,85 @@ export default class VelociousHttpServerClientWebsocketSession {
830
971
  this._bufferedBytes = 0
831
972
  }
832
973
 
974
+ /**
975
+ * Tentatively admits one complete text message before decoding it.
976
+ * @param {number} byteLength - Exact complete raw text payload bytes.
977
+ * @returns {InboundMessageAdmission | null} - Admission ownership, or null after overload/close.
978
+ */
979
+ _admitInboundMessage(byteLength) {
980
+ if (this._inboundClosed) return null
981
+
982
+ if (
983
+ this._inboundPendingMessages + 1 > this._inboundMaxPendingMessages ||
984
+ this._inboundPendingBytes + byteLength > this._inboundMaxPendingBytes
985
+ ) {
986
+ this._closeForInboundBacklog(byteLength)
987
+ return null
988
+ }
989
+
990
+ this._inboundPendingMessages += 1
991
+ this._inboundPendingBytes += byteLength
992
+
993
+ return {
994
+ byteLength,
995
+ generation: this._inboundAccountingGeneration,
996
+ released: false
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Releases one admission exactly once.
1002
+ * @param {InboundMessageAdmission} admission - Admission ownership.
1003
+ * @returns {void}
1004
+ */
1005
+ _releaseInboundAdmission(admission) {
1006
+ if (admission.released) return
1007
+
1008
+ admission.released = true
1009
+ if (admission.generation !== this._inboundAccountingGeneration) return
1010
+
1011
+ this._inboundPendingMessages -= 1
1012
+ this._inboundPendingBytes -= admission.byteLength
1013
+ }
1014
+
1015
+ /**
1016
+ * Abandons all admitted input and invalidates late settlements.
1017
+ * @returns {void}
1018
+ */
1019
+ _abandonInboundMessages() {
1020
+ this._inboundClosed = true
1021
+ this._inboundAccountingGeneration += 1
1022
+ this._inboundPendingBytes = 0
1023
+ this._inboundPendingMessages = 0
1024
+ this.messageQueue = []
1025
+ }
1026
+
1027
+ /**
1028
+ * Permanently closes a session whose next message exceeded its backlog budget.
1029
+ * @param {number} rejectedBytes - Raw payload bytes rejected at admission.
1030
+ * @returns {void}
1031
+ */
1032
+ _closeForInboundBacklog(rejectedBytes) {
1033
+ if (this._inboundBacklogOverloaded || this._inboundClosed) return
1034
+
1035
+ this._inboundBacklogOverloaded = true
1036
+ this.logger.warn(() => [
1037
+ "Inbound websocket message backlog exceeded; closing connection",
1038
+ {
1039
+ maxBytes: this._inboundMaxPendingBytes,
1040
+ maxMessages: this._inboundMaxPendingMessages,
1041
+ pendingBytes: this._inboundPendingBytes,
1042
+ pendingMessages: this._inboundPendingMessages,
1043
+ rejectedBytes
1044
+ }
1045
+ ])
1046
+ this.sendGoodbye(this.client, {
1047
+ code: WEBSOCKET_CLOSE_POLICY_VIOLATION,
1048
+ reason: WEBSOCKET_INBOUND_BACKLOG_CLOSE_REASON
1049
+ })
1050
+ this._handleClose({allowResume: false})
1051
+ }
1052
+
833
1053
  /**
834
1054
  * Closes after an inbound buffering limit and releases all parser-owned input.
835
1055
  * @returns {void}
@@ -1081,14 +1301,23 @@ export default class VelociousHttpServerClientWebsocketSession {
1081
1301
  return true
1082
1302
  }
1083
1303
 
1084
- _handleClose() {
1304
+ /**
1305
+ * Handles socket closure and optionally retains resumable state.
1306
+ * @param {{allowResume?: boolean}} [options] - Closure behavior.
1307
+ * @returns {void}
1308
+ */
1309
+ _handleClose({allowResume = true} = {}) {
1310
+ this._resetFragmentBuffer()
1311
+ this._clearBufferedFrameChunks()
1312
+ this._abandonInboundMessages()
1313
+
1085
1314
  // If the session has resumable state (live Connection or
1086
1315
  // ChannelV2 subscription), move it into the paused registry
1087
1316
  // instead of tearing down; a new socket presenting the sessionId
1088
1317
  // via `session-resume` within the grace window will reattach.
1089
1318
  const hasResumableState = this._connections.size > 0 || this._channelSubscriptions.size > 0
1090
1319
 
1091
- if (hasResumableState && !this._paused) {
1320
+ if (allowResume && hasResumableState && !this._paused) {
1092
1321
  // Paused sessions have no live socket to ping; the grace timer
1093
1322
  // owns their eventual teardown from here.
1094
1323
  this._stopHeartbeat()
@@ -1123,6 +1352,9 @@ export default class VelociousHttpServerClientWebsocketSession {
1123
1352
  */
1124
1353
  _finalizeGraceExpiry() {
1125
1354
  this._stopHeartbeat()
1355
+ this._resetFragmentBuffer()
1356
+ this._clearBufferedFrameChunks()
1357
+ this._abandonInboundMessages()
1126
1358
  this.configuration._websocketSessions.delete(this)
1127
1359
  void this._runMessageHandlerClose()
1128
1360
  void this._teardownChannel()
@@ -1145,7 +1377,11 @@ export default class VelociousHttpServerClientWebsocketSession {
1145
1377
 
1146
1378
  try {
1147
1379
  return await resolver(this)
1148
- } catch (error) {
1380
+ } catch (caughtError) {
1381
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1382
+ stage: "websocket-session-identity-pause"
1383
+ })
1384
+
1149
1385
  this.logger.error(() => ["Websocket session identity resolver failed at pause", error])
1150
1386
  return undefined
1151
1387
  }
@@ -1180,7 +1416,13 @@ export default class VelociousHttpServerClientWebsocketSession {
1180
1416
  for (const connection of this._connections.values()) {
1181
1417
  try {
1182
1418
  await connection[callbackName]?.()
1183
- } catch (error) {
1419
+ } catch (caughtError) {
1420
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1421
+ callbackName,
1422
+ connectionId: connection.connectionId,
1423
+ stage: "websocket-connection-lifecycle"
1424
+ })
1425
+
1184
1426
  this.logger.error(() => [`${callbackName} failed for ${connection.connectionId}`, error])
1185
1427
  }
1186
1428
  }
@@ -1188,7 +1430,13 @@ export default class VelociousHttpServerClientWebsocketSession {
1188
1430
  for (const {subscription} of this._channelSubscriptions.values()) {
1189
1431
  try {
1190
1432
  await subscription[callbackName]?.()
1191
- } catch (error) {
1433
+ } catch (caughtError) {
1434
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1435
+ callbackName,
1436
+ stage: "websocket-channel-lifecycle",
1437
+ subscriptionId: subscription.subscriptionId
1438
+ })
1439
+
1192
1440
  this.logger.error(() => [`${callbackName} failed for channel sub ${subscription.subscriptionId}`, error])
1193
1441
  }
1194
1442
  }
@@ -1229,7 +1477,11 @@ export default class VelociousHttpServerClientWebsocketSession {
1229
1477
 
1230
1478
  try {
1231
1479
  freshIdentity = await resolver(this)
1232
- } catch (error) {
1480
+ } catch (caughtError) {
1481
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1482
+ stage: "websocket-session-identity-resume"
1483
+ })
1484
+
1233
1485
  this.logger.error(() => ["Websocket session identity resolver failed at resume", error])
1234
1486
  freshIdentity = undefined
1235
1487
  }
@@ -1295,7 +1547,13 @@ export default class VelociousHttpServerClientWebsocketSession {
1295
1547
  await this._withConnections(async () => {
1296
1548
  await connection.onClose(reason)
1297
1549
  })
1298
- } catch (error) {
1550
+ } catch (caughtError) {
1551
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1552
+ connectionId: connection.connectionId,
1553
+ reason,
1554
+ stage: "websocket-connection-teardown"
1555
+ })
1556
+
1299
1557
  this.logger.error(() => [`Failed to tear down connection ${connection.connectionId}`, error])
1300
1558
  }
1301
1559
  }
@@ -1346,9 +1604,16 @@ export default class VelociousHttpServerClientWebsocketSession {
1346
1604
  // can never be routed to a partially initialized connection.
1347
1605
  this._connections.set(connectionId, connection)
1348
1606
  this.sendJson({type: "connection-opened", connectionId})
1349
- } catch (error) {
1607
+ } catch (caughtError) {
1608
+ const clientErrorMessage = caughtError instanceof Error ? caughtError.message : ""
1609
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1610
+ connectionId,
1611
+ connectionType,
1612
+ stage: "websocket-connection-open"
1613
+ })
1614
+
1350
1615
  this.logger.error(() => [`Failed to open connection ${connectionType}:${connectionId}`, error])
1351
- this.sendJson({type: "connection-error", connectionId, message: /** @type {Error} */ (error).message || "Failed to open connection"})
1616
+ this.sendJson({type: "connection-error", connectionId, message: clientErrorMessage || "Failed to open connection"})
1352
1617
  }
1353
1618
  }
1354
1619
 
@@ -1370,9 +1635,15 @@ export default class VelociousHttpServerClientWebsocketSession {
1370
1635
  await this._withConnections(async () => {
1371
1636
  await connection.onMessage(message.body)
1372
1637
  })
1373
- } catch (error) {
1638
+ } catch (caughtError) {
1639
+ const clientErrorMessage = caughtError instanceof Error ? caughtError.message : ""
1640
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1641
+ connectionId,
1642
+ stage: "websocket-connection-message"
1643
+ })
1644
+
1374
1645
  this.logger.error(() => [`Failed to handle connection-message for ${connectionId}`, error])
1375
- this.sendJson({type: "connection-error", connectionId, message: /** @type {Error} */ (error).message || "Failed to handle message"})
1646
+ this.sendJson({type: "connection-error", connectionId, message: clientErrorMessage || "Failed to handle message"})
1376
1647
  }
1377
1648
  }
1378
1649
 
@@ -1397,7 +1668,12 @@ export default class VelociousHttpServerClientWebsocketSession {
1397
1668
  await this._withConnections(async () => {
1398
1669
  await connection.onClose("client_close")
1399
1670
  })
1400
- } catch (error) {
1671
+ } catch (caughtError) {
1672
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1673
+ connectionId,
1674
+ stage: "websocket-connection-close"
1675
+ })
1676
+
1401
1677
  this.logger.error(() => [`Failed to tear down connection ${connectionId}`, error])
1402
1678
  }
1403
1679
 
@@ -1479,11 +1755,19 @@ export default class VelociousHttpServerClientWebsocketSession {
1479
1755
 
1480
1756
  this.sendJson({type: "channel-subscribed", subscriptionId})
1481
1757
  })
1482
- } catch (error) {
1758
+ } catch (caughtError) {
1759
+ const clientErrorMessage = caughtError instanceof Error ? caughtError.message : ""
1760
+
1483
1761
  this._channelSubscriptions.delete(subscriptionId)
1484
1762
  this.configuration._unregisterWebsocketChannelSubscription(channelType, subscription)
1763
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1764
+ channelType,
1765
+ stage: "websocket-channel-subscribe",
1766
+ subscriptionId
1767
+ })
1768
+
1485
1769
  this.logger.error(() => [`Failed to subscribe channel ${channelType}:${subscriptionId}`, error])
1486
- this.sendJson({type: "channel-error", subscriptionId, message: /** @type {Error} */ (error).message || "Failed to subscribe"})
1770
+ this.sendJson({type: "channel-error", subscriptionId, message: clientErrorMessage || "Failed to subscribe"})
1487
1771
  }
1488
1772
  }
1489
1773
 
@@ -1551,7 +1835,13 @@ export default class VelociousHttpServerClientWebsocketSession {
1551
1835
 
1552
1836
  try {
1553
1837
  await this._withConnections(async () => await entry.subscription.unsubscribed())
1554
- } catch (error) {
1838
+ } catch (caughtError) {
1839
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1840
+ channelType: entry.channelType,
1841
+ stage: "websocket-channel-unsubscribe",
1842
+ subscriptionId
1843
+ })
1844
+
1555
1845
  this.logger.error(() => [`Failed to unsubscribe channel ${entry.channelType}:${subscriptionId}`, error])
1556
1846
  }
1557
1847
 
@@ -1576,7 +1866,13 @@ export default class VelociousHttpServerClientWebsocketSession {
1576
1866
 
1577
1867
  try {
1578
1868
  await this._withConnections(async () => await subscription.unsubscribed())
1579
- } catch (error) {
1869
+ } catch (caughtError) {
1870
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1871
+ channelType,
1872
+ stage: "websocket-channel-teardown",
1873
+ subscriptionId: subscription.subscriptionId
1874
+ })
1875
+
1580
1876
  this.logger.error(() => [`Failed to tear down channel-v2 ${channelType}:${subscription.subscriptionId}`, error])
1581
1877
  }
1582
1878
  }
@@ -1604,7 +1900,11 @@ export default class VelociousHttpServerClientWebsocketSession {
1604
1900
  await channel?.unsubscribed?.()
1605
1901
  })
1606
1902
  })
1607
- } catch (error) {
1903
+ } catch (caughtError) {
1904
+ const error = this._reportUnexpectedDispatchError(caughtError, {
1905
+ stage: "websocket-channel-teardown"
1906
+ })
1907
+
1608
1908
  this.logger.error(() => ["Failed to teardown websocket channel", error])
1609
1909
  }
1610
1910
 
@@ -1703,7 +2003,12 @@ export default class VelociousHttpServerClientWebsocketSession {
1703
2003
  }
1704
2004
 
1705
2005
  await this._registerChannel(channelInstance, tenant)
1706
- } catch (error) {
2006
+ } catch (caughtError) {
2007
+ const error = this._reportUnexpectedDispatchError(caughtError, {
2008
+ channel,
2009
+ stage: "websocket-channel-subscription"
2010
+ })
2011
+
1707
2012
  this.logger.warn(() => ["Websocket channel subscription failed", error])
1708
2013
  this.sendJson({channel, error: "Subscription rejected", type: "error"})
1709
2014
  }
@@ -1830,7 +2135,11 @@ export default class VelociousHttpServerClientWebsocketSession {
1830
2135
  if (onOpen) {
1831
2136
  await onOpen({session: this})
1832
2137
  }
1833
- } catch (error) {
2138
+ } catch (caughtError) {
2139
+ const error = this._reportUnexpectedDispatchError(caughtError, {
2140
+ stage: "websocket-message-handler-open"
2141
+ })
2142
+
1834
2143
  this.logger.error(() => ["Websocket open handler failed", error])
1835
2144
  }
1836
2145
  }
@@ -1848,13 +2157,31 @@ export default class VelociousHttpServerClientWebsocketSession {
1848
2157
  if (onMessage) {
1849
2158
  await onMessage({message, session: this})
1850
2159
  }
1851
- } catch (error) {
1852
- this.logger.error(() => ["Websocket message handler failed", error])
2160
+ } catch (caughtError) {
1853
2161
  const handler = this.messageHandler
1854
2162
  const onError = handler ? handler.onError : null
2163
+ const error = this._reportUnexpectedDispatchError(caughtError, {
2164
+ stage: "websocket-message-handler"
2165
+ })
2166
+
2167
+ this.logger.error(() => ["Websocket message handler failed", error])
2168
+ if (!onError) return
1855
2169
 
1856
- if (onError) {
1857
- await onError({error: error instanceof Error ? error : new Error(String(error)), session: this})
2170
+ try {
2171
+ await onError({error, session: this})
2172
+ } catch (onErrorCaughtError) {
2173
+ const clientErrorMessage = onErrorCaughtError instanceof Error
2174
+ ? onErrorCaughtError.message
2175
+ : String(onErrorCaughtError)
2176
+ const onErrorError = this._reportUnexpectedDispatchError(onErrorCaughtError, {
2177
+ stage: "websocket-message-handler-error"
2178
+ })
2179
+
2180
+ this.logger.error(() => ["Websocket message error handler failed", onErrorError])
2181
+ this.sendJson({
2182
+ error: clientErrorMessage,
2183
+ type: "error"
2184
+ })
1858
2185
  }
1859
2186
  }
1860
2187
  }
@@ -1867,7 +2194,11 @@ export default class VelociousHttpServerClientWebsocketSession {
1867
2194
  if (onClose) {
1868
2195
  await onClose({session: this})
1869
2196
  }
1870
- } catch (error) {
2197
+ } catch (caughtError) {
2198
+ const error = this._reportUnexpectedDispatchError(caughtError, {
2199
+ stage: "websocket-message-handler-close"
2200
+ })
2201
+
1871
2202
  this.logger.error(() => ["Websocket close handler failed", error])
1872
2203
  }
1873
2204
  }
@@ -1893,30 +2224,65 @@ export default class VelociousHttpServerClientWebsocketSession {
1893
2224
  async _resolveMessageHandlerPromise() {
1894
2225
  if (!this.messageHandlerPromise) return
1895
2226
 
2227
+ /** @type {import("../../configuration-types.js").WebsocketMessageHandler | void} */
2228
+ let handler
2229
+
1896
2230
  try {
1897
- const handler = await this.messageHandlerPromise
1898
-
1899
- if (handler) {
1900
- this.pendingMessageHandler = false
1901
- this.messageHandlerPromise = undefined
1902
- // Install handler and drain onOpen before replaying queued
1903
- // messages. setMessageHandler() fires onOpen as fire-and-forget;
1904
- // awaiting _runMessageHandlerOpen() directly here closes the
1905
- // race where queued subscribe/connection-* frames would
1906
- // dispatch while an async onOpen is still setting up session
1907
- // state.
1908
- this.messageHandler = handler
1909
- await this._runMessageHandlerOpen()
1910
- await this._flushQueuedMessages({useHandler: typeof handler.onMessage === "function"})
1911
- return
1912
- }
1913
- } catch (error) {
2231
+ handler = await this.messageHandlerPromise
2232
+ } catch (caughtError) {
2233
+ const error = this._reportUnexpectedDispatchError(caughtError, {
2234
+ stage: "websocket-message-handler-resolver"
2235
+ })
2236
+
1914
2237
  this.logger.error(() => ["Websocket message handler resolver failed", error])
2238
+ this.messageHandlerPromise = undefined
2239
+ await this._finishMessageHandlerResolution({useHandler: false})
2240
+ return
1915
2241
  }
1916
2242
 
1917
- this.pendingMessageHandler = false
1918
2243
  this.messageHandlerPromise = undefined
1919
- await this._flushQueuedMessages({useHandler: false})
2244
+ if (!handler) {
2245
+ await this._finishMessageHandlerResolution({useHandler: false})
2246
+ return
2247
+ }
2248
+
2249
+ if (this._inboundClosed) {
2250
+ this.pendingMessageHandler = false
2251
+ return
2252
+ }
2253
+
2254
+ // Install handler and drain onOpen before replaying queued
2255
+ // messages. setMessageHandler() fires onOpen as fire-and-forget;
2256
+ // awaiting _runMessageHandlerOpen() directly here closes the
2257
+ // race where queued subscribe/connection-* frames would
2258
+ // dispatch while an async onOpen is still setting up session
2259
+ // state.
2260
+ this.messageHandler = handler
2261
+ await this._runMessageHandlerOpen()
2262
+ await this._finishMessageHandlerResolution({
2263
+ useHandler: typeof handler.onMessage === "function"
2264
+ })
2265
+ }
2266
+
2267
+ /**
2268
+ * Inserts resolver completion into the FIFO chain before allowing new dispatch.
2269
+ * @param {{useHandler: boolean}} args - Resolver result.
2270
+ * @returns {Promise<void>} - Resolves after queued messages drain.
2271
+ */
2272
+ async _finishMessageHandlerResolution({useHandler}) {
2273
+ const previous = this._messageChain
2274
+ const drain = previous.then(async () => {
2275
+ this.pendingMessageHandler = false
2276
+ if (this._inboundClosed) {
2277
+ this.messageQueue = []
2278
+ return
2279
+ }
2280
+
2281
+ await this._flushQueuedMessages({useHandler})
2282
+ })
2283
+
2284
+ this._messageChain = drain.catch(() => {})
2285
+ await drain
1920
2286
  }
1921
2287
 
1922
2288
  /**
@@ -1927,14 +2293,34 @@ export default class VelociousHttpServerClientWebsocketSession {
1927
2293
  async _flushQueuedMessages({useHandler}) {
1928
2294
  if (this.messageQueue.length === 0) return
1929
2295
 
1930
- const queued = this.messageQueue.slice()
2296
+ const queued = this.messageQueue
1931
2297
  this.messageQueue = []
1932
2298
 
1933
- for (const message of queued) {
1934
- if (useHandler && this.messageHandler) {
1935
- await this._runMessageHandlerMessage(message)
1936
- } else {
1937
- await this._handleMessage(message)
2299
+ for (const work of queued) {
2300
+ if (this._inboundClosed) {
2301
+ this._releaseInboundAdmission(work.admission)
2302
+ continue
2303
+ }
2304
+
2305
+ try {
2306
+ if (useHandler && this.messageHandler) {
2307
+ await this._runMessageHandlerMessage(work.message)
2308
+ } else {
2309
+ await this._dispatchMessage(work.message)
2310
+ }
2311
+ } catch (caughtError) {
2312
+ const clientErrorMessage = caughtError instanceof Error ? caughtError.message : String(caughtError)
2313
+ const error = this._reportUnexpectedDispatchError(caughtError, {
2314
+ stage: "websocket-message-dispatch"
2315
+ })
2316
+
2317
+ this.logger.error(() => ["Websocket message handler failed", error])
2318
+ this.sendJson({
2319
+ error: clientErrorMessage,
2320
+ type: "error"
2321
+ })
2322
+ } finally {
2323
+ this._releaseInboundAdmission(work.admission)
1938
2324
  }
1939
2325
  }
1940
2326
  }