velocious 1.0.565 → 1.0.566
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/README.md +3 -1
- package/build/configuration-types.js +1 -0
- package/build/configuration.js +20 -0
- package/build/http-server/client/websocket-session.js +455 -69
- package/build/src/configuration-types.d.ts +8 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +2 -1
- package/build/src/configuration.d.ts +12 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +19 -1
- package/build/src/http-server/client/websocket-session.d.ts +102 -4
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +404 -67
- package/package.json +1 -1
- package/src/configuration-types.js +1 -0
- package/src/configuration.js +20 -0
- package/src/http-server/client/websocket-session.js +455 -69
|
@@ -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
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
562
|
-
if (!path) throw
|
|
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.
|
|
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({
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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:
|
|
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 (
|
|
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:
|
|
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 (
|
|
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 (
|
|
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:
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1857
|
-
await onError({error
|
|
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 (
|
|
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
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
|
|
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
|
|
2296
|
+
const queued = this.messageQueue
|
|
1931
2297
|
this.messageQueue = []
|
|
1932
2298
|
|
|
1933
|
-
for (const
|
|
1934
|
-
if (
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
}
|