@fluojs/websockets 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.ko.md +133 -0
- package/README.md +133 -0
- package/dist/bun/bun-module.d.ts +15 -0
- package/dist/bun/bun-module.d.ts.map +1 -0
- package/dist/bun/bun-module.js +27 -0
- package/dist/bun/bun-service.d.ts +63 -0
- package/dist/bun/bun-service.d.ts.map +1 -0
- package/dist/bun/bun-service.js +558 -0
- package/dist/bun/bun-types.d.ts +58 -0
- package/dist/bun/bun-types.d.ts.map +1 -0
- package/dist/bun/bun-types.js +1 -0
- package/dist/bun/bun.d.ts +4 -0
- package/dist/bun/bun.d.ts.map +1 -0
- package/dist/bun/bun.js +3 -0
- package/dist/bun.d.ts +2 -0
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +1 -0
- package/dist/cloudflare-workers/cloudflare-workers-module.d.ts +15 -0
- package/dist/cloudflare-workers/cloudflare-workers-module.d.ts.map +1 -0
- package/dist/cloudflare-workers/cloudflare-workers-module.js +27 -0
- package/dist/cloudflare-workers/cloudflare-workers-service.d.ts +61 -0
- package/dist/cloudflare-workers/cloudflare-workers-service.d.ts.map +1 -0
- package/dist/cloudflare-workers/cloudflare-workers-service.js +538 -0
- package/dist/cloudflare-workers/cloudflare-workers-types.d.ts +30 -0
- package/dist/cloudflare-workers/cloudflare-workers-types.d.ts.map +1 -0
- package/dist/cloudflare-workers/cloudflare-workers-types.js +1 -0
- package/dist/cloudflare-workers/cloudflare-workers.d.ts +4 -0
- package/dist/cloudflare-workers/cloudflare-workers.d.ts.map +1 -0
- package/dist/cloudflare-workers/cloudflare-workers.js +3 -0
- package/dist/cloudflare-workers.d.ts +2 -0
- package/dist/cloudflare-workers.d.ts.map +1 -0
- package/dist/cloudflare-workers.js +1 -0
- package/dist/decorators.d.ts +56 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/decorators.js +115 -0
- package/dist/deno/deno-module.d.ts +15 -0
- package/dist/deno/deno-module.d.ts.map +1 -0
- package/dist/deno/deno-module.js +27 -0
- package/dist/deno/deno-service.d.ts +61 -0
- package/dist/deno/deno-service.d.ts.map +1 -0
- package/dist/deno/deno-service.js +533 -0
- package/dist/deno/deno-types.d.ts +25 -0
- package/dist/deno/deno-types.d.ts.map +1 -0
- package/dist/deno/deno-types.js +1 -0
- package/dist/deno/deno.d.ts +4 -0
- package/dist/deno/deno.d.ts.map +1 -0
- package/dist/deno/deno.js +3 -0
- package/dist/deno.d.ts +2 -0
- package/dist/deno.d.ts.map +1 -0
- package/dist/deno.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/internal/shared.d.ts +28 -0
- package/dist/internal/shared.d.ts.map +1 -0
- package/dist/internal/shared.js +188 -0
- package/dist/metadata.d.ts +13 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +81 -0
- package/dist/module.d.ts +26 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +26 -0
- package/dist/node/node-module.d.ts +15 -0
- package/dist/node/node-module.d.ts.map +1 -0
- package/dist/node/node-module.js +27 -0
- package/dist/node/node-service.d.ts +129 -0
- package/dist/node/node-service.d.ts.map +1 -0
- package/dist/node/node-service.js +892 -0
- package/dist/node/node-types.d.ts +81 -0
- package/dist/node/node-types.d.ts.map +1 -0
- package/dist/node/node-types.js +1 -0
- package/dist/node/node.d.ts +4 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +3 -0
- package/dist/node.d.ts +2 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +1 -0
- package/dist/options-token.internal.d.ts +7 -0
- package/dist/options-token.internal.d.ts.map +1 -0
- package/dist/options-token.internal.js +4 -0
- package/dist/service.d.ts +9 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +8 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +91 -0
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
let _initClass;
|
|
2
|
+
function _applyDecs(e, t, n, r, o, i) { var a, c, u, s, f, l, p, d = Symbol.metadata || Symbol.for("Symbol.metadata"), m = Object.defineProperty, h = Object.create, y = [h(null), h(null)], v = t.length; function g(t, n, r) { return function (o, i) { n && (i = o, o = e); for (var a = 0; a < t.length; a++) i = t[a].apply(o, r ? [i] : []); return r ? i : o; }; } function b(e, t, n, r) { if ("function" != typeof e && (r || void 0 !== e)) throw new TypeError(t + " must " + (n || "be") + " a function" + (r ? "" : " or undefined")); return e; } function applyDec(e, t, n, r, o, i, u, s, f, l, p) { function d(e) { if (!p(e)) throw new TypeError("Attempted to access private element on non-instance"); } var h = [].concat(t[0]), v = t[3], w = !u, D = 1 === o, S = 3 === o, j = 4 === o, E = 2 === o; function I(t, n, r) { return function (o, i) { return n && (i = o, o = e), r && r(o), P[t].call(o, i); }; } if (!w) { var P = {}, k = [], F = S ? "get" : j || D ? "set" : "value"; if (f ? (l || D ? P = { get: _setFunctionName(function () { return v(this); }, r, "get"), set: function (e) { t[4](this, e); } } : P[F] = v, l || _setFunctionName(P[F], r, E ? "" : F)) : l || (P = Object.getOwnPropertyDescriptor(e, r)), !l && !f) { if ((c = y[+s][r]) && 7 !== (c ^ o)) throw Error("Decorating two elements with the same name (" + P[F].name + ") is not supported yet"); y[+s][r] = o < 3 ? 1 : o; } } for (var N = e, O = h.length - 1; O >= 0; O -= n ? 2 : 1) { var T = b(h[O], "A decorator", "be", !0), z = n ? h[O - 1] : void 0, A = {}, H = { kind: ["field", "accessor", "method", "getter", "setter", "class"][o], name: r, metadata: a, addInitializer: function (e, t) { if (e.v) throw new TypeError("attempted to call addInitializer after decoration was finished"); b(t, "An initializer", "be", !0), i.push(t); }.bind(null, A) }; if (w) c = T.call(z, N, H), A.v = 1, b(c, "class decorators", "return") && (N = c);else if (H.static = s, H.private = f, c = H.access = { has: f ? p.bind() : function (e) { return r in e; } }, j || (c.get = f ? E ? function (e) { return d(e), P.value; } : I("get", 0, d) : function (e) { return e[r]; }), E || S || (c.set = f ? I("set", 0, d) : function (e, t) { e[r] = t; }), N = T.call(z, D ? { get: P.get, set: P.set } : P[F], H), A.v = 1, D) { if ("object" == typeof N && N) (c = b(N.get, "accessor.get")) && (P.get = c), (c = b(N.set, "accessor.set")) && (P.set = c), (c = b(N.init, "accessor.init")) && k.unshift(c);else if (void 0 !== N) throw new TypeError("accessor decorators must return an object with get, set, or init properties or undefined"); } else b(N, (l ? "field" : "method") + " decorators", "return") && (l ? k.unshift(N) : P[F] = N); } return o < 2 && u.push(g(k, s, 1), g(i, s, 0)), l || w || (f ? D ? u.splice(-1, 0, I("get", s), I("set", s)) : u.push(E ? P[F] : b.call.bind(P[F])) : m(e, r, P)), N; } function w(e) { return m(e, d, { configurable: !0, enumerable: !0, value: a }); } return void 0 !== i && (a = i[d]), a = h(null == a ? null : a), f = [], l = function (e) { e && f.push(g(e)); }, p = function (t, r) { for (var i = 0; i < n.length; i++) { var a = n[i], c = a[1], l = 7 & c; if ((8 & c) == t && !l == r) { var p = a[2], d = !!a[3], m = 16 & c; applyDec(t ? e : e.prototype, a, m, d ? "#" + p : _toPropertyKey(p), l, l < 2 ? [] : t ? s = s || [] : u = u || [], f, !!t, d, r, t && d ? function (t) { return _checkInRHS(t) === e; } : o); } } }, p(8, 0), p(0, 0), p(8, 1), p(0, 1), l(u), l(s), c = f, v || w(e), { e: c, get c() { var n = []; return v && [w(e = applyDec(e, [t], r, e.name, 5, n)), g(n, 1)]; } }; }
|
|
3
|
+
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
|
|
4
|
+
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
|
|
5
|
+
function _setFunctionName(e, t, n) { "symbol" == typeof t && (t = (t = t.description) ? "[" + t + "]" : ""); try { Object.defineProperty(e, "name", { configurable: !0, value: n ? n + " " + t : t }); } catch (e) {} return e; }
|
|
6
|
+
function _checkInRHS(e) { if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); return e; }
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
9
|
+
import { Inject } from '@fluojs/core';
|
|
10
|
+
import { APPLICATION_LOGGER, COMPILED_MODULES, HTTP_APPLICATION_ADAPTER, RUNTIME_CONTAINER } from '@fluojs/runtime/internal';
|
|
11
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
12
|
+
import { dispatchGatewayDisconnect, dispatchGatewayMessage, discoverGatewayDescriptors, isFinitePositiveInteger, normalizeGatewayPath, resolveGatewayInstance, runGatewayHandlers } from '../internal/shared.js';
|
|
13
|
+
import { WEBSOCKET_OPTIONS_INTERNAL } from '../options-token.internal.js';
|
|
14
|
+
function resolveHttpStatusText(status) {
|
|
15
|
+
switch (status) {
|
|
16
|
+
case 400:
|
|
17
|
+
return 'Bad Request';
|
|
18
|
+
case 401:
|
|
19
|
+
return 'Unauthorized';
|
|
20
|
+
case 403:
|
|
21
|
+
return 'Forbidden';
|
|
22
|
+
case 404:
|
|
23
|
+
return 'Not Found';
|
|
24
|
+
case 413:
|
|
25
|
+
return 'Payload Too Large';
|
|
26
|
+
case 426:
|
|
27
|
+
return 'Upgrade Required';
|
|
28
|
+
case 429:
|
|
29
|
+
return 'Too Many Requests';
|
|
30
|
+
case 500:
|
|
31
|
+
return 'Internal Server Error';
|
|
32
|
+
case 503:
|
|
33
|
+
return 'Service Unavailable';
|
|
34
|
+
default:
|
|
35
|
+
return 'Rejected';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function isUpgradeRejection(value) {
|
|
39
|
+
return typeof value === 'object' && value !== null && 'status' in value;
|
|
40
|
+
}
|
|
41
|
+
function isHttpExceptionLike(error) {
|
|
42
|
+
return typeof error === 'object' && error !== null && 'message' in error && 'status' in error;
|
|
43
|
+
}
|
|
44
|
+
function resolveMessageByteLength(data) {
|
|
45
|
+
if (typeof data === 'string') {
|
|
46
|
+
return Buffer.byteLength(data);
|
|
47
|
+
}
|
|
48
|
+
if (data instanceof ArrayBuffer) {
|
|
49
|
+
return data.byteLength;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(data)) {
|
|
52
|
+
return data.reduce((length, chunk) => length + chunk.byteLength, 0);
|
|
53
|
+
}
|
|
54
|
+
return data.byteLength;
|
|
55
|
+
}
|
|
56
|
+
const DEFAULT_WEBSOCKET_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
57
|
+
const DEFAULT_MAX_PENDING_MESSAGES_PER_SOCKET = 256;
|
|
58
|
+
const DEFAULT_MAX_BUFFERED_AMOUNT_BYTES = 1_048_576;
|
|
59
|
+
const DEFAULT_MAX_WEBSOCKET_CONNECTIONS = 1_000;
|
|
60
|
+
const DEFAULT_MAX_WEBSOCKET_PAYLOAD_BYTES = 1_048_576;
|
|
61
|
+
const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
62
|
+
function hasNodeUpgradeServer(value) {
|
|
63
|
+
if (typeof value !== 'object' || value === null) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const maybeServer = value;
|
|
67
|
+
return typeof maybeServer.on === 'function' && typeof maybeServer.off === 'function';
|
|
68
|
+
}
|
|
69
|
+
function resolveServerBackedRealtimeCapability(adapter) {
|
|
70
|
+
if (typeof adapter.getRealtimeCapability !== 'function') {
|
|
71
|
+
throw new Error('WebSocket gateway bootstrap requires an HTTP adapter with getRealtimeCapability(). Use a platform adapter that exposes a server-backed realtime capability.');
|
|
72
|
+
}
|
|
73
|
+
const capability = adapter.getRealtimeCapability();
|
|
74
|
+
if (capability.kind !== 'server-backed') {
|
|
75
|
+
throw new Error(`WebSocket gateway bootstrap requires a server-backed realtime capability. ${capability.reason}`);
|
|
76
|
+
}
|
|
77
|
+
return capability;
|
|
78
|
+
}
|
|
79
|
+
function rejectUpgradeRequest(socket) {
|
|
80
|
+
if (socket.destroyed || socket.writableEnded) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
84
|
+
socket.destroy();
|
|
85
|
+
}
|
|
86
|
+
function rejectBadUpgradeRequest(socket) {
|
|
87
|
+
if (socket.destroyed || socket.writableEnded) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
91
|
+
socket.destroy();
|
|
92
|
+
}
|
|
93
|
+
function rejectUpgradeRequestWithStatus(socket, rejection) {
|
|
94
|
+
if (socket.destroyed || socket.writableEnded) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const body = rejection.body ?? '';
|
|
98
|
+
let response = `HTTP/1.1 ${String(rejection.status)} ${resolveHttpStatusText(rejection.status)}\r\n`;
|
|
99
|
+
for (const [header, value] of Object.entries(rejection.headers ?? {})) {
|
|
100
|
+
response += `${header}: ${value}\r\n`;
|
|
101
|
+
}
|
|
102
|
+
response += `Connection: close\r\nContent-Length: ${String(Buffer.byteLength(body))}\r\n\r\n${body}`;
|
|
103
|
+
socket.write(response);
|
|
104
|
+
socket.destroy();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Lifecycle service that discovers WebSocket gateways, attaches upgrade listeners, and manages room state.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* This service preserves the documented runtime behavior for shared-path discovery order, optional server-backed
|
|
112
|
+
* listeners, buffered pre-ready events, heartbeat handling, and graceful shutdown.
|
|
113
|
+
*/
|
|
114
|
+
let _NodeWebSocketGateway;
|
|
115
|
+
class NodeWebSocketGatewayLifecycleService {
|
|
116
|
+
static {
|
|
117
|
+
[_NodeWebSocketGateway, _initClass] = _applyDecs(this, [Inject(RUNTIME_CONTAINER, COMPILED_MODULES, APPLICATION_LOGGER, HTTP_APPLICATION_ADAPTER, WEBSOCKET_OPTIONS_INTERNAL)], []).c;
|
|
118
|
+
}
|
|
119
|
+
attachments = [];
|
|
120
|
+
heartbeatTimer;
|
|
121
|
+
ownedUpgradeServers = [];
|
|
122
|
+
pendingUpgradeReservations = 0;
|
|
123
|
+
pingPending = new Set();
|
|
124
|
+
pingSentAt = new Map();
|
|
125
|
+
roomSockets = new Map();
|
|
126
|
+
shutdownPromise;
|
|
127
|
+
socketRegistry = new Map();
|
|
128
|
+
socketRooms = new Map();
|
|
129
|
+
upgradeListener;
|
|
130
|
+
upgradeServer;
|
|
131
|
+
constructor(runtimeContainer, compiledModules, logger, adapter, moduleOptions) {
|
|
132
|
+
this.runtimeContainer = runtimeContainer;
|
|
133
|
+
this.compiledModules = compiledModules;
|
|
134
|
+
this.logger = logger;
|
|
135
|
+
this.adapter = adapter;
|
|
136
|
+
this.moduleOptions = moduleOptions;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Discovers gateway classes and attaches their WebSocket servers once per application lifecycle.
|
|
141
|
+
*/
|
|
142
|
+
async onApplicationBootstrap() {
|
|
143
|
+
if (this.upgradeListener || this.ownedUpgradeServers.length > 0 || this.attachments.length > 0) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const descriptors = discoverGatewayDescriptors(this.compiledModules, this.logger, 'WebSocketGatewayLifecycleService');
|
|
147
|
+
if (descriptors.length === 0) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await this.prepareGatewayAttachments(descriptors);
|
|
151
|
+
this.startHeartbeatIfEnabled();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Shuts down websocket listeners and connection tracking during application shutdown.
|
|
156
|
+
*/
|
|
157
|
+
async onApplicationShutdown() {
|
|
158
|
+
await this.shutdown();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Shuts down websocket listeners and connection tracking when the containing module is destroyed.
|
|
163
|
+
*/
|
|
164
|
+
async onModuleDestroy() {
|
|
165
|
+
await this.shutdown();
|
|
166
|
+
}
|
|
167
|
+
async prepareGatewayAttachments(descriptors) {
|
|
168
|
+
const attachmentGroups = this.buildGatewayAttachmentGroups(descriptors);
|
|
169
|
+
for (const group of attachmentGroups.values()) {
|
|
170
|
+
this.attachConnectionHandlersToServers(group.attachmentsByPath);
|
|
171
|
+
}
|
|
172
|
+
await this.attachGatewayServers(attachmentGroups);
|
|
173
|
+
this.attachments = Array.from(attachmentGroups.values()).flatMap(group => Array.from(group.attachmentsByPath.values()));
|
|
174
|
+
}
|
|
175
|
+
async attachGatewayServers(attachmentGroups) {
|
|
176
|
+
for (const group of attachmentGroups.values()) {
|
|
177
|
+
if (group.target.kind === 'application-server') {
|
|
178
|
+
this.attachUpgradeServerListener(group.attachmentsByPath);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
await this.attachOwnedGatewayServerListener(group);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
attachUpgradeServerListener(attachmentsByPath) {
|
|
185
|
+
const upgradeServer = this.resolveUpgradeServer();
|
|
186
|
+
const listener = this.createUpgradeListener(upgradeServer, attachmentsByPath);
|
|
187
|
+
upgradeServer.on('upgrade', listener);
|
|
188
|
+
this.upgradeServer = upgradeServer;
|
|
189
|
+
this.upgradeListener = listener;
|
|
190
|
+
}
|
|
191
|
+
async attachOwnedGatewayServerListener(group) {
|
|
192
|
+
if (group.target.kind !== 'owned-server') {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const server = createOwnedGatewayServer();
|
|
196
|
+
const listener = this.createUpgradeListener(server, group.attachmentsByPath);
|
|
197
|
+
server.on('upgrade', listener);
|
|
198
|
+
await this.listenOwnedGatewayServer(server, group.target.port);
|
|
199
|
+
this.ownedUpgradeServers.push({
|
|
200
|
+
listener,
|
|
201
|
+
port: group.target.port,
|
|
202
|
+
server
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
startHeartbeatIfEnabled() {
|
|
206
|
+
const heartbeat = this.moduleOptions.heartbeat;
|
|
207
|
+
if (heartbeat?.enabled === false) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const intervalMs = heartbeat?.intervalMs ?? DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS;
|
|
211
|
+
const timeoutMs = heartbeat?.timeoutMs ?? intervalMs;
|
|
212
|
+
this.startHeartbeat(intervalMs, timeoutMs);
|
|
213
|
+
}
|
|
214
|
+
buildGatewayAttachmentGroups(descriptors) {
|
|
215
|
+
const attachmentGroups = new Map();
|
|
216
|
+
for (const descriptor of descriptors) {
|
|
217
|
+
const bindingTarget = this.resolveBindingTarget(descriptor);
|
|
218
|
+
const group = attachmentGroups.get(bindingTarget.key) ?? this.createAttachmentGroup(bindingTarget);
|
|
219
|
+
const current = group.attachmentsByPath.get(descriptor.path);
|
|
220
|
+
if (current) {
|
|
221
|
+
current.descriptors.push(descriptor);
|
|
222
|
+
attachmentGroups.set(bindingTarget.key, group);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
group.attachmentsByPath.set(descriptor.path, {
|
|
226
|
+
bindingTarget,
|
|
227
|
+
descriptors: [descriptor],
|
|
228
|
+
path: descriptor.path,
|
|
229
|
+
server: new WebSocketServer({
|
|
230
|
+
maxPayload: this.resolveMaxPayloadBytes(),
|
|
231
|
+
noServer: true
|
|
232
|
+
})
|
|
233
|
+
});
|
|
234
|
+
attachmentGroups.set(bindingTarget.key, group);
|
|
235
|
+
}
|
|
236
|
+
return attachmentGroups;
|
|
237
|
+
}
|
|
238
|
+
createAttachmentGroup(target) {
|
|
239
|
+
return {
|
|
240
|
+
attachmentsByPath: new Map(),
|
|
241
|
+
target
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
resolveBindingTarget(descriptor) {
|
|
245
|
+
const serverBacked = descriptor.serverBacked;
|
|
246
|
+
if (!serverBacked) {
|
|
247
|
+
return {
|
|
248
|
+
key: 'application-server',
|
|
249
|
+
kind: 'application-server'
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const port = this.resolveServerBackedPort(descriptor.path, serverBacked);
|
|
253
|
+
return {
|
|
254
|
+
key: `owned-server:${String(port)}`,
|
|
255
|
+
kind: 'owned-server',
|
|
256
|
+
port
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
resolveServerBackedPort(path, options) {
|
|
260
|
+
if (!isFinitePositiveInteger(options.port)) {
|
|
261
|
+
throw new Error(`WebSocket gateway serverBacked.port for path ${path} must be a finite positive integer.`);
|
|
262
|
+
}
|
|
263
|
+
return options.port;
|
|
264
|
+
}
|
|
265
|
+
attachConnectionHandlersToServers(attachmentsByPath) {
|
|
266
|
+
for (const attachment of attachmentsByPath.values()) {
|
|
267
|
+
attachment.server.on('connection', (socket, request) => {
|
|
268
|
+
void this.bindConnectionHandlers(attachment.descriptors, socket, request);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
createUpgradeListener(upgradeServer, attachmentsByPath) {
|
|
273
|
+
return (request, socket, head) => {
|
|
274
|
+
socket.pause();
|
|
275
|
+
void this.handleUpgradeRequest(upgradeServer, attachmentsByPath, request, socket, head).catch(error => {
|
|
276
|
+
this.logger.error('WebSocket upgrade admission failed.', error, 'WebSocketGatewayLifecycleService');
|
|
277
|
+
rejectUpgradeRequestWithStatus(socket, {
|
|
278
|
+
body: 'Internal server error',
|
|
279
|
+
status: 500
|
|
280
|
+
});
|
|
281
|
+
}).finally(() => {
|
|
282
|
+
if (!socket.destroyed) {
|
|
283
|
+
socket.resume();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
async handleUpgradeRequest(upgradeServer, attachmentsByPath, request, socket, head) {
|
|
289
|
+
let attachment;
|
|
290
|
+
let targetPath;
|
|
291
|
+
try {
|
|
292
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
293
|
+
targetPath = normalizeGatewayPath(url.pathname);
|
|
294
|
+
attachment = attachmentsByPath.get(targetPath);
|
|
295
|
+
} catch {
|
|
296
|
+
rejectBadUpgradeRequest(socket);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!attachment) {
|
|
300
|
+
if (upgradeServer.listenerCount('upgrade') === 1) {
|
|
301
|
+
rejectUpgradeRequest(socket);
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const rejection = await this.resolveUpgradeRejection(request, targetPath);
|
|
306
|
+
if (rejection) {
|
|
307
|
+
rejectUpgradeRequestWithStatus(socket, rejection);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
attachment.server.handleUpgrade(request, socket, head, websocket => {
|
|
312
|
+
attachment.server.emit('connection', websocket, request);
|
|
313
|
+
});
|
|
314
|
+
} catch (error) {
|
|
315
|
+
this.releaseUpgradeReservation();
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
resolveUpgradeServer() {
|
|
320
|
+
const capability = resolveServerBackedRealtimeCapability(this.adapter);
|
|
321
|
+
const server = capability.server;
|
|
322
|
+
if (!hasNodeUpgradeServer(server)) {
|
|
323
|
+
throw new Error('WebSocket gateway bootstrap requires the selected realtime capability to expose a Node HTTP/S server that supports upgrade listeners.');
|
|
324
|
+
}
|
|
325
|
+
return server;
|
|
326
|
+
}
|
|
327
|
+
async bindConnectionHandlers(descriptors, socket, request) {
|
|
328
|
+
const state = this.createConnectionHandlerState();
|
|
329
|
+
this.registerSocketConnection(state, socket);
|
|
330
|
+
this.attachConnectionListeners(state, socket, request);
|
|
331
|
+
await this.resolveConnectionGateways(descriptors, state);
|
|
332
|
+
await this.runConnectHandlers(state, socket, request);
|
|
333
|
+
await this.finalizeConnectionBinding(state, socket, request);
|
|
334
|
+
}
|
|
335
|
+
registerSocketConnection(state, socket) {
|
|
336
|
+
this.releaseUpgradeReservation();
|
|
337
|
+
this.socketRegistry.set(state.socketId, socket);
|
|
338
|
+
}
|
|
339
|
+
async finalizeConnectionBinding(state, socket, request) {
|
|
340
|
+
state.handlersReady = true;
|
|
341
|
+
this.replayBufferedConnectionEvents(state, socket, request);
|
|
342
|
+
await state.handlerQueue;
|
|
343
|
+
}
|
|
344
|
+
createConnectionHandlerState() {
|
|
345
|
+
return {
|
|
346
|
+
bufferedDisconnect: undefined,
|
|
347
|
+
bufferedMessages: [],
|
|
348
|
+
bufferedMessagesStartIndex: 0,
|
|
349
|
+
enqueuedMessageCount: 0,
|
|
350
|
+
handlerQueue: Promise.resolve(),
|
|
351
|
+
handlersReady: false,
|
|
352
|
+
processingMessageQueue: false,
|
|
353
|
+
queuedMessages: [],
|
|
354
|
+
queuedMessagesStartIndex: 0,
|
|
355
|
+
resolved: [],
|
|
356
|
+
socketId: randomUUID()
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
getBufferedMessageCount(state) {
|
|
360
|
+
return state.bufferedMessages.length - (state.bufferedMessagesStartIndex ?? 0);
|
|
361
|
+
}
|
|
362
|
+
getQueuedMessageCount(state) {
|
|
363
|
+
return state.queuedMessages.length - (state.queuedMessagesStartIndex ?? 0);
|
|
364
|
+
}
|
|
365
|
+
maybeCompactBufferedMessages(state) {
|
|
366
|
+
const startIndex = state.bufferedMessagesStartIndex ?? 0;
|
|
367
|
+
if (startIndex === 0 || startIndex < state.bufferedMessages.length / 2) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
state.bufferedMessages = state.bufferedMessages.slice(startIndex);
|
|
371
|
+
state.bufferedMessagesStartIndex = 0;
|
|
372
|
+
}
|
|
373
|
+
clearBufferedMessages(state) {
|
|
374
|
+
state.bufferedMessages = [];
|
|
375
|
+
state.bufferedMessagesStartIndex = 0;
|
|
376
|
+
}
|
|
377
|
+
maybeCompactQueuedMessages(state) {
|
|
378
|
+
const startIndex = state.queuedMessagesStartIndex ?? 0;
|
|
379
|
+
if (startIndex === 0 || startIndex < state.queuedMessages.length / 2) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
state.queuedMessages = state.queuedMessages.slice(startIndex);
|
|
383
|
+
state.queuedMessagesStartIndex = 0;
|
|
384
|
+
}
|
|
385
|
+
clearQueuedMessages(state) {
|
|
386
|
+
state.queuedMessages = [];
|
|
387
|
+
state.queuedMessagesStartIndex = 0;
|
|
388
|
+
state.enqueuedMessageCount = 0;
|
|
389
|
+
}
|
|
390
|
+
enqueueMessageDispatch(state, socket, request, data) {
|
|
391
|
+
const limit = isFinitePositiveInteger(this.moduleOptions.buffer?.maxPendingMessagesPerSocket) ? this.moduleOptions.buffer.maxPendingMessagesPerSocket : DEFAULT_MAX_PENDING_MESSAGES_PER_SOCKET;
|
|
392
|
+
const policy = this.moduleOptions.buffer?.overflowPolicy ?? 'drop-oldest';
|
|
393
|
+
if (this.getQueuedMessageCount(state) >= limit) {
|
|
394
|
+
if (policy === 'close') {
|
|
395
|
+
socket.terminate();
|
|
396
|
+
this.clearQueuedMessages(state);
|
|
397
|
+
this.unregisterSocket(state.socketId);
|
|
398
|
+
this.logger.warn(`WebSocket connection ${state.socketId} exceeded ready-state message queue limit (${String(limit)}). Connection terminated.`, 'WebSocketGatewayLifecycleService');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (policy === 'drop-oldest') {
|
|
402
|
+
state.queuedMessagesStartIndex = (state.queuedMessagesStartIndex ?? 0) + 1;
|
|
403
|
+
this.maybeCompactQueuedMessages(state);
|
|
404
|
+
this.logger.warn(`WebSocket connection ${state.socketId} dropped the oldest ready-state message because queue limit (${String(limit)}) was reached.`, 'WebSocketGatewayLifecycleService');
|
|
405
|
+
} else {
|
|
406
|
+
this.logger.warn(`WebSocket connection ${state.socketId} dropped a ready-state message because queue limit (${String(limit)}) was reached.`, 'WebSocketGatewayLifecycleService');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
state.queuedMessages.push(data);
|
|
411
|
+
state.enqueuedMessageCount = this.getQueuedMessageCount(state);
|
|
412
|
+
if (state.processingMessageQueue) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
state.processingMessageQueue = true;
|
|
416
|
+
state.handlerQueue = this.drainMessageQueue(state, socket, request).finally(() => {
|
|
417
|
+
state.processingMessageQueue = false;
|
|
418
|
+
state.enqueuedMessageCount = this.getQueuedMessageCount(state);
|
|
419
|
+
}).catch(error => {
|
|
420
|
+
this.logger.error('WebSocket gateway message dispatch failed.', error, 'WebSocketGatewayLifecycleService');
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
async drainMessageQueue(state, socket, request) {
|
|
424
|
+
while ((state.queuedMessagesStartIndex ?? 0) < state.queuedMessages.length) {
|
|
425
|
+
const nextMessage = state.queuedMessages[state.queuedMessagesStartIndex ?? 0];
|
|
426
|
+
state.queuedMessagesStartIndex = (state.queuedMessagesStartIndex ?? 0) + 1;
|
|
427
|
+
state.enqueuedMessageCount = this.getQueuedMessageCount(state);
|
|
428
|
+
if (nextMessage === undefined) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
await this.handleMessage(state.resolved, socket, request, nextMessage);
|
|
432
|
+
}
|
|
433
|
+
this.clearQueuedMessages(state);
|
|
434
|
+
}
|
|
435
|
+
async handleMessage(resolved, socket, request, data) {
|
|
436
|
+
await dispatchGatewayMessage(resolved, socket, request, data, this.logger, 'WebSocketGatewayLifecycleService');
|
|
437
|
+
}
|
|
438
|
+
enqueueDisconnectDispatch(state, socket, disconnectEvent) {
|
|
439
|
+
state.handlerQueue = state.handlerQueue.then(async () => {
|
|
440
|
+
await this.handleDisconnect(state.resolved, socket, disconnectEvent.code, disconnectEvent.reason, state.socketId);
|
|
441
|
+
}).catch(error => {
|
|
442
|
+
this.logger.error('WebSocket gateway disconnect dispatch failed.', error, 'WebSocketGatewayLifecycleService');
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
attachConnectionListeners(state, socket, request) {
|
|
446
|
+
socket.on('message', data => {
|
|
447
|
+
if (this.closeOversizedPayload(state.socketId, socket, data)) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (!state.handlersReady) {
|
|
451
|
+
this.bufferIncomingMessage(state, socket, data);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
this.enqueueMessageDispatch(state, socket, request, data);
|
|
455
|
+
});
|
|
456
|
+
socket.on('pong', () => {
|
|
457
|
+
this.pingPending.delete(state.socketId);
|
|
458
|
+
this.pingSentAt.delete(state.socketId);
|
|
459
|
+
});
|
|
460
|
+
socket.on('error', error => {
|
|
461
|
+
this.unregisterSocket(state.socketId);
|
|
462
|
+
this.logger.error('WebSocket gateway socket emitted an error.', error, 'WebSocketGatewayLifecycleService');
|
|
463
|
+
});
|
|
464
|
+
socket.on('close', (code, reason) => {
|
|
465
|
+
this.unregisterSocket(state.socketId);
|
|
466
|
+
const disconnectEvent = {
|
|
467
|
+
code,
|
|
468
|
+
reason
|
|
469
|
+
};
|
|
470
|
+
if (!state.handlersReady) {
|
|
471
|
+
state.bufferedDisconnect = disconnectEvent;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
this.enqueueDisconnectDispatch(state, socket, disconnectEvent);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
bufferIncomingMessage(state, socket, data) {
|
|
478
|
+
const limit = isFinitePositiveInteger(this.moduleOptions.buffer?.maxPendingMessagesPerSocket) ? this.moduleOptions.buffer.maxPendingMessagesPerSocket : DEFAULT_MAX_PENDING_MESSAGES_PER_SOCKET;
|
|
479
|
+
const policy = this.moduleOptions.buffer?.overflowPolicy ?? 'drop-oldest';
|
|
480
|
+
if (this.getBufferedMessageCount(state) < limit) {
|
|
481
|
+
state.bufferedMessages.push(data);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (policy === 'close') {
|
|
485
|
+
socket.terminate();
|
|
486
|
+
this.clearBufferedMessages(state);
|
|
487
|
+
this.logger.warn(`WebSocket connection ${state.socketId} exceeded pending message buffer limit (${String(limit)}). Connection terminated.`, 'WebSocketGatewayLifecycleService');
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (policy === 'drop-newest') {
|
|
491
|
+
this.logger.warn(`WebSocket connection ${state.socketId} dropped an incoming message due to pending buffer limit (${String(limit)}).`, 'WebSocketGatewayLifecycleService');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
state.bufferedMessagesStartIndex = (state.bufferedMessagesStartIndex ?? 0) + 1;
|
|
495
|
+
this.maybeCompactBufferedMessages(state);
|
|
496
|
+
state.bufferedMessages.push(data);
|
|
497
|
+
this.logger.warn(`WebSocket connection ${state.socketId} dropped the oldest pending message due to buffer limit (${String(limit)}).`, 'WebSocketGatewayLifecycleService');
|
|
498
|
+
}
|
|
499
|
+
async resolveConnectionGateways(descriptors, state) {
|
|
500
|
+
for (const descriptor of descriptors) {
|
|
501
|
+
const instance = await resolveGatewayInstance(this.runtimeContainer, descriptor, this.logger, 'WebSocketGatewayLifecycleService');
|
|
502
|
+
if (instance !== undefined) {
|
|
503
|
+
state.resolved.push({
|
|
504
|
+
descriptor,
|
|
505
|
+
instance
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async runConnectHandlers(state, socket, request) {
|
|
511
|
+
for (const {
|
|
512
|
+
descriptor,
|
|
513
|
+
instance
|
|
514
|
+
} of state.resolved) {
|
|
515
|
+
await runGatewayHandlers(instance, descriptor, 'connect', [socket, request, state.socketId], this.logger, 'WebSocketGatewayLifecycleService');
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
replayBufferedConnectionEvents(state, socket, request) {
|
|
519
|
+
for (let index = state.bufferedMessagesStartIndex ?? 0; index < state.bufferedMessages.length; index += 1) {
|
|
520
|
+
const message = state.bufferedMessages[index];
|
|
521
|
+
this.enqueueMessageDispatch(state, socket, request, message);
|
|
522
|
+
}
|
|
523
|
+
if (state.bufferedDisconnect) {
|
|
524
|
+
this.enqueueDisconnectDispatch(state, socket, state.bufferedDisconnect);
|
|
525
|
+
state.bufferedDisconnect = undefined;
|
|
526
|
+
this.clearBufferedMessages(state);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
this.clearBufferedMessages(state);
|
|
530
|
+
if (socket.readyState !== WebSocket.OPEN && socket.readyState !== WebSocket.CONNECTING) {
|
|
531
|
+
this.unregisterSocket(state.socketId);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async handleDisconnect(resolved, socket, code, reason, socketId) {
|
|
535
|
+
await dispatchGatewayDisconnect(resolved, socket, code, reason.toString('utf8'), socketId, this.logger, 'WebSocketGatewayLifecycleService');
|
|
536
|
+
}
|
|
537
|
+
async resolveUpgradeRejection(request, path) {
|
|
538
|
+
if (!this.tryReserveUpgradeSlot()) {
|
|
539
|
+
return {
|
|
540
|
+
body: 'WebSocket connection limit exceeded.',
|
|
541
|
+
status: 429
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const guard = this.moduleOptions.upgrade?.guard;
|
|
545
|
+
if (!guard) {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const result = await guard(request, {
|
|
550
|
+
activeConnectionCount: this.resolveReservedConnectionCount() - 1,
|
|
551
|
+
path
|
|
552
|
+
});
|
|
553
|
+
if (result === false) {
|
|
554
|
+
this.releaseUpgradeReservation();
|
|
555
|
+
return {
|
|
556
|
+
body: 'WebSocket upgrade rejected.',
|
|
557
|
+
status: 403
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (isUpgradeRejection(result)) {
|
|
561
|
+
this.releaseUpgradeReservation();
|
|
562
|
+
return result;
|
|
563
|
+
}
|
|
564
|
+
return undefined;
|
|
565
|
+
} catch (error) {
|
|
566
|
+
this.releaseUpgradeReservation();
|
|
567
|
+
if (isHttpExceptionLike(error)) {
|
|
568
|
+
return {
|
|
569
|
+
body: error.message,
|
|
570
|
+
status: error.status
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
closeOversizedPayload(socketId, socket, data) {
|
|
577
|
+
const maxPayloadBytes = this.resolveMaxPayloadBytes();
|
|
578
|
+
if (resolveMessageByteLength(data) <= maxPayloadBytes) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
socket.close(1009, 'Payload too large');
|
|
582
|
+
this.logger.warn(`WebSocket connection ${socketId} exceeded payload limit (${String(maxPayloadBytes)} bytes). Connection closed.`, 'WebSocketGatewayLifecycleService');
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
resolveMaxConnectionCount() {
|
|
586
|
+
const configured = this.moduleOptions.limits?.maxConnections;
|
|
587
|
+
if (!isFinitePositiveInteger(configured)) {
|
|
588
|
+
return DEFAULT_MAX_WEBSOCKET_CONNECTIONS;
|
|
589
|
+
}
|
|
590
|
+
return configured;
|
|
591
|
+
}
|
|
592
|
+
resolveReservedConnectionCount() {
|
|
593
|
+
return this.socketRegistry.size + this.pendingUpgradeReservations;
|
|
594
|
+
}
|
|
595
|
+
tryReserveUpgradeSlot() {
|
|
596
|
+
if (this.resolveReservedConnectionCount() >= this.resolveMaxConnectionCount()) {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
this.pendingUpgradeReservations += 1;
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
releaseUpgradeReservation() {
|
|
603
|
+
if (this.pendingUpgradeReservations > 0) {
|
|
604
|
+
this.pendingUpgradeReservations -= 1;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
resolveMaxPayloadBytes() {
|
|
608
|
+
const configured = this.moduleOptions.limits?.maxPayloadBytes;
|
|
609
|
+
if (!isFinitePositiveInteger(configured)) {
|
|
610
|
+
return DEFAULT_MAX_WEBSOCKET_PAYLOAD_BYTES;
|
|
611
|
+
}
|
|
612
|
+
return configured;
|
|
613
|
+
}
|
|
614
|
+
resolveShutdownTimeoutMs() {
|
|
615
|
+
const configured = this.moduleOptions.shutdown?.timeoutMs;
|
|
616
|
+
if (typeof configured !== 'number' || !Number.isFinite(configured) || configured <= 0) {
|
|
617
|
+
return DEFAULT_WEBSOCKET_SHUTDOWN_TIMEOUT_MS;
|
|
618
|
+
}
|
|
619
|
+
return Math.floor(configured);
|
|
620
|
+
}
|
|
621
|
+
closeServerWithTimeout(attachment, timeoutMs) {
|
|
622
|
+
return new Promise((resolve, reject) => {
|
|
623
|
+
let settled = false;
|
|
624
|
+
const timeout = setTimeout(() => {
|
|
625
|
+
if (settled) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
settled = true;
|
|
629
|
+
reject(new Error(`Timed out while closing websocket server for path "${attachment.path}" after ${String(timeoutMs)}ms.`));
|
|
630
|
+
}, timeoutMs);
|
|
631
|
+
attachment.server.close(error => {
|
|
632
|
+
if (settled) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
settled = true;
|
|
636
|
+
clearTimeout(timeout);
|
|
637
|
+
if (error) {
|
|
638
|
+
reject(error);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
resolve();
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
async shutdown() {
|
|
646
|
+
if (this.shutdownPromise) {
|
|
647
|
+
await this.shutdownPromise;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
this.shutdownPromise = this.runShutdownLifecycle();
|
|
651
|
+
await this.shutdownPromise;
|
|
652
|
+
}
|
|
653
|
+
async runShutdownLifecycle() {
|
|
654
|
+
this.stopHeartbeat();
|
|
655
|
+
this.detachUpgradeServerListener();
|
|
656
|
+
const attachments = this.attachments.splice(0);
|
|
657
|
+
const ownedUpgradeServers = this.ownedUpgradeServers.splice(0);
|
|
658
|
+
const shutdownTimeoutMs = this.resolveShutdownTimeoutMs();
|
|
659
|
+
await this.closeGatewayAttachments(attachments, shutdownTimeoutMs);
|
|
660
|
+
await this.closeOwnedUpgradeServers(ownedUpgradeServers, shutdownTimeoutMs);
|
|
661
|
+
this.clearConnectionTrackingState();
|
|
662
|
+
}
|
|
663
|
+
stopHeartbeat() {
|
|
664
|
+
if (!this.heartbeatTimer) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
clearInterval(this.heartbeatTimer);
|
|
668
|
+
this.heartbeatTimer = undefined;
|
|
669
|
+
}
|
|
670
|
+
detachUpgradeServerListener() {
|
|
671
|
+
if (this.upgradeServer && this.upgradeListener) {
|
|
672
|
+
this.upgradeServer.off('upgrade', this.upgradeListener);
|
|
673
|
+
}
|
|
674
|
+
for (const registration of this.ownedUpgradeServers) {
|
|
675
|
+
registration.server.off('upgrade', registration.listener);
|
|
676
|
+
}
|
|
677
|
+
this.upgradeServer = undefined;
|
|
678
|
+
this.upgradeListener = undefined;
|
|
679
|
+
}
|
|
680
|
+
async closeOwnedUpgradeServers(registrations, shutdownTimeoutMs) {
|
|
681
|
+
await Promise.all(registrations.map(async registration => {
|
|
682
|
+
try {
|
|
683
|
+
await this.closeOwnedUpgradeServerWithTimeout(registration, shutdownTimeoutMs);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
this.logger.error(`Failed to close owned websocket listener on port ${String(registration.port)} within ${String(shutdownTimeoutMs)}ms.`, error, 'WebSocketGatewayLifecycleService');
|
|
686
|
+
}
|
|
687
|
+
}));
|
|
688
|
+
}
|
|
689
|
+
closeOwnedUpgradeServerWithTimeout(registration, timeoutMs) {
|
|
690
|
+
return new Promise((resolve, reject) => {
|
|
691
|
+
let settled = false;
|
|
692
|
+
const timeout = setTimeout(() => {
|
|
693
|
+
if (settled) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
settled = true;
|
|
697
|
+
reject(new Error(`Timed out while closing owned websocket listener on port ${String(registration.port)} after ${String(timeoutMs)}ms.`));
|
|
698
|
+
}, timeoutMs);
|
|
699
|
+
registration.server.close(error => {
|
|
700
|
+
if (settled) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
settled = true;
|
|
704
|
+
clearTimeout(timeout);
|
|
705
|
+
if (error) {
|
|
706
|
+
reject(error);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
resolve();
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
listenOwnedGatewayServer(server, port) {
|
|
714
|
+
return new Promise((resolve, reject) => {
|
|
715
|
+
const httpServer = server;
|
|
716
|
+
const onError = error => {
|
|
717
|
+
reject(error);
|
|
718
|
+
};
|
|
719
|
+
httpServer.once('error', onError);
|
|
720
|
+
server.listen(port, () => {
|
|
721
|
+
httpServer.off('error', onError);
|
|
722
|
+
resolve();
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
async closeGatewayAttachments(attachments, shutdownTimeoutMs) {
|
|
727
|
+
await Promise.all(attachments.map(async attachment => {
|
|
728
|
+
this.terminateAttachmentClients(attachment);
|
|
729
|
+
await this.closeGatewayAttachment(attachment, shutdownTimeoutMs);
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
terminateAttachmentClients(attachment) {
|
|
733
|
+
for (const client of attachment.server.clients) {
|
|
734
|
+
client.terminate();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async closeGatewayAttachment(attachment, shutdownTimeoutMs) {
|
|
738
|
+
try {
|
|
739
|
+
await this.closeServerWithTimeout(attachment, shutdownTimeoutMs);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
this.logger.error(`Failed to close websocket server for path ${attachment.path} within ${String(shutdownTimeoutMs)}ms.`, error, 'WebSocketGatewayLifecycleService');
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
clearConnectionTrackingState() {
|
|
745
|
+
this.socketRegistry.clear();
|
|
746
|
+
this.socketRooms.clear();
|
|
747
|
+
this.roomSockets.clear();
|
|
748
|
+
this.pingPending.clear();
|
|
749
|
+
this.pingSentAt.clear();
|
|
750
|
+
this.pendingUpgradeReservations = 0;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Adds one socket to an in-memory room membership set.
|
|
755
|
+
*
|
|
756
|
+
* @param socketId Socket identifier to add.
|
|
757
|
+
* @param room Room identifier to join.
|
|
758
|
+
*/
|
|
759
|
+
joinRoom(socketId, room) {
|
|
760
|
+
let rooms = this.socketRooms.get(socketId);
|
|
761
|
+
if (!rooms) {
|
|
762
|
+
rooms = new Set();
|
|
763
|
+
this.socketRooms.set(socketId, rooms);
|
|
764
|
+
}
|
|
765
|
+
rooms.add(room);
|
|
766
|
+
let sockets = this.roomSockets.get(room);
|
|
767
|
+
if (!sockets) {
|
|
768
|
+
sockets = new Set();
|
|
769
|
+
this.roomSockets.set(room, sockets);
|
|
770
|
+
}
|
|
771
|
+
sockets.add(socketId);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Removes one socket from an in-memory room membership set.
|
|
776
|
+
*
|
|
777
|
+
* @param socketId Socket identifier to remove.
|
|
778
|
+
* @param room Room identifier to leave.
|
|
779
|
+
*/
|
|
780
|
+
leaveRoom(socketId, room) {
|
|
781
|
+
const rooms = this.socketRooms.get(socketId);
|
|
782
|
+
rooms?.delete(room);
|
|
783
|
+
if (rooms && rooms.size === 0) {
|
|
784
|
+
this.socketRooms.delete(socketId);
|
|
785
|
+
}
|
|
786
|
+
const sockets = this.roomSockets.get(room);
|
|
787
|
+
sockets?.delete(socketId);
|
|
788
|
+
if (sockets && sockets.size === 0) {
|
|
789
|
+
this.roomSockets.delete(room);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Broadcasts one JSON-encoded event frame to every open socket currently joined to a room.
|
|
795
|
+
*
|
|
796
|
+
* @param room Room identifier that should receive the event.
|
|
797
|
+
* @param event Event name delivered to room members.
|
|
798
|
+
* @param data Payload delivered with the event.
|
|
799
|
+
*/
|
|
800
|
+
broadcastToRoom(room, event, data) {
|
|
801
|
+
const socketIds = this.roomSockets.get(room);
|
|
802
|
+
if (!socketIds) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const message = JSON.stringify({
|
|
806
|
+
data,
|
|
807
|
+
event
|
|
808
|
+
});
|
|
809
|
+
const maxBufferedAmountBytes = isFinitePositiveInteger(this.moduleOptions.backpressure?.maxBufferedAmountBytes) ? this.moduleOptions.backpressure.maxBufferedAmountBytes : DEFAULT_MAX_BUFFERED_AMOUNT_BYTES;
|
|
810
|
+
const backpressurePolicy = this.moduleOptions.backpressure?.policy ?? 'drop';
|
|
811
|
+
for (const socketId of socketIds) {
|
|
812
|
+
const socket = this.socketRegistry.get(socketId);
|
|
813
|
+
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
814
|
+
if (socket.bufferedAmount > maxBufferedAmountBytes) {
|
|
815
|
+
if (backpressurePolicy === 'close') {
|
|
816
|
+
socket.terminate();
|
|
817
|
+
this.unregisterSocket(socketId);
|
|
818
|
+
this.logger.warn(`WebSocket connection ${socketId} exceeded bufferedAmount threshold (${String(maxBufferedAmountBytes)} bytes). Connection terminated.`, 'WebSocketGatewayLifecycleService');
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
this.logger.warn(`WebSocket connection ${socketId} exceeded bufferedAmount threshold (${String(maxBufferedAmountBytes)} bytes). Broadcast frame dropped.`, 'WebSocketGatewayLifecycleService');
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
socket.send(message);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Returns the current in-memory room snapshot for one socket.
|
|
831
|
+
*
|
|
832
|
+
* @param socketId Socket identifier to inspect.
|
|
833
|
+
* @returns The room set currently tracked for that socket.
|
|
834
|
+
*/
|
|
835
|
+
getRooms(socketId) {
|
|
836
|
+
const rooms = this.socketRooms.get(socketId);
|
|
837
|
+
if (!rooms) {
|
|
838
|
+
return new Set();
|
|
839
|
+
}
|
|
840
|
+
return new Set(rooms);
|
|
841
|
+
}
|
|
842
|
+
startHeartbeat(intervalMs, timeoutMs) {
|
|
843
|
+
if (this.heartbeatTimer) {
|
|
844
|
+
clearInterval(this.heartbeatTimer);
|
|
845
|
+
}
|
|
846
|
+
this.heartbeatTimer = setInterval(() => {
|
|
847
|
+
const now = Date.now();
|
|
848
|
+
for (const [socketId, socket] of this.socketRegistry) {
|
|
849
|
+
if (this.pingPending.has(socketId)) {
|
|
850
|
+
const pingAt = this.pingSentAt.get(socketId);
|
|
851
|
+
const elapsed = pingAt === undefined ? timeoutMs : now - pingAt;
|
|
852
|
+
if (elapsed >= timeoutMs) {
|
|
853
|
+
socket.terminate();
|
|
854
|
+
this.unregisterSocket(socketId);
|
|
855
|
+
}
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
859
|
+
this.pingPending.add(socketId);
|
|
860
|
+
this.pingSentAt.set(socketId, now);
|
|
861
|
+
socket.ping();
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}, intervalMs);
|
|
865
|
+
}
|
|
866
|
+
unregisterSocket(socketId) {
|
|
867
|
+
this.socketRegistry.delete(socketId);
|
|
868
|
+
this.pingPending.delete(socketId);
|
|
869
|
+
this.pingSentAt.delete(socketId);
|
|
870
|
+
const rooms = this.socketRooms.get(socketId);
|
|
871
|
+
if (rooms) {
|
|
872
|
+
for (const room of rooms) {
|
|
873
|
+
const sockets = this.roomSockets.get(room);
|
|
874
|
+
sockets?.delete(socketId);
|
|
875
|
+
if (sockets && sockets.size === 0) {
|
|
876
|
+
this.roomSockets.delete(room);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
this.socketRooms.delete(socketId);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
static {
|
|
883
|
+
_initClass();
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
export { _NodeWebSocketGateway as NodeWebSocketGatewayLifecycleService };
|
|
887
|
+
function createOwnedGatewayServer() {
|
|
888
|
+
return createHttpServer((_request, response) => {
|
|
889
|
+
response.statusCode = 404;
|
|
890
|
+
response.end();
|
|
891
|
+
});
|
|
892
|
+
}
|