@fluojs/websockets 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -11,6 +11,7 @@ import { WEBSOCKET_OPTIONS_INTERNAL } from '../options-token.internal.js';
11
11
  const DEFAULT_MAX_PENDING_MESSAGES_PER_SOCKET = 256;
12
12
  const DEFAULT_MAX_WEBSOCKET_CONNECTIONS = 1_000;
13
13
  const DEFAULT_MAX_WEBSOCKET_PAYLOAD_BYTES = 1_048_576;
14
+ const DEFAULT_WEBSOCKET_SHUTDOWN_TIMEOUT_MS = 5_000;
14
15
  const LIFECYCLE_LOG_CONTEXT = 'WebSocketGatewayLifecycleService';
15
16
  const WEBSOCKET_OPEN_READY_STATE = 1;
16
17
  function hasDenoWebSocketBindingHost(adapter) {
@@ -41,6 +42,16 @@ function resolveMessageByteLength(message) {
41
42
  }
42
43
  return message.size;
43
44
  }
45
+ function createCompletionSignal() {
46
+ let resolve;
47
+ const promise = new Promise(res => {
48
+ resolve = res;
49
+ });
50
+ return {
51
+ promise,
52
+ resolve
53
+ };
54
+ }
44
55
 
45
56
  /**
46
57
  * Boots Deno-backed websocket gateways and manages their room lifecycle state.
@@ -50,11 +61,14 @@ class DenoWebSocketGatewayLifecycleService {
50
61
  static {
51
62
  [_DenoWebSocketGateway, _initClass] = _applyDecs(this, [Inject(RUNTIME_CONTAINER, COMPILED_MODULES, APPLICATION_LOGGER, HTTP_APPLICATION_ADAPTER, WEBSOCKET_OPTIONS_INTERNAL)], []).c;
52
63
  }
64
+ isShuttingDown = false;
65
+ pendingUpgradeOperations = new Set();
53
66
  pendingUpgradeReservations = 0;
54
67
  roomSockets = new Map();
55
68
  shutdownPromise;
56
69
  socketRegistry = new Map();
57
70
  socketRooms = new Map();
71
+ socketStates = new Map();
58
72
  constructor(runtimeContainer, compiledModules, logger, adapter, moduleOptions) {
59
73
  this.runtimeContainer = runtimeContainer;
60
74
  this.compiledModules = compiledModules;
@@ -90,53 +104,60 @@ class DenoWebSocketGatewayLifecycleService {
90
104
  createBinding(descriptors) {
91
105
  const descriptorsByPath = this.groupDescriptorsByPath(descriptors);
92
106
  return {
93
- fetch: async (request, host) => {
94
- if (!isWebSocketUpgradeRequest(request)) {
95
- return new Response(null, {
96
- status: 426
97
- });
98
- }
99
- let targetPath;
100
- try {
101
- targetPath = normalizeGatewayPath(new URL(request.url).pathname);
102
- } catch {
103
- return new Response(null, {
104
- status: 400
105
- });
106
- }
107
- const descriptors = descriptorsByPath.get(targetPath);
108
- if (!descriptors) {
109
- return new Response(null, {
110
- status: 404
111
- });
112
- }
113
- const rejection = await this.resolveUpgradeRejection(request, targetPath);
114
- if (rejection) {
115
- return new Response(rejection.body ?? null, {
116
- headers: rejection.headers,
117
- status: rejection.status
118
- });
119
- }
120
- let response;
121
- let socket;
122
- try {
123
- ({
124
- response,
125
- socket
126
- } = host.upgrade(request));
127
- } catch (error) {
128
- this.releaseUpgradeReservation();
129
- throw error;
130
- }
131
- void this.bindConnectionHandlers(socket, request, descriptors).catch(error => {
132
- this.unregisterSocket(this.findSocketId(socket));
133
- this.logger.error('WebSocket gateway open lifecycle failed.', error, LIFECYCLE_LOG_CONTEXT);
134
- socket.close(1011, 'Internal server error');
135
- });
136
- return response;
137
- }
107
+ fetch: (request, host) => this.trackPendingUpgradeOperation(this.handleUpgradeRequest(request, host, descriptorsByPath))
138
108
  };
139
109
  }
110
+ async handleUpgradeRequest(request, host, descriptorsByPath) {
111
+ if (!isWebSocketUpgradeRequest(request)) {
112
+ return new Response(null, {
113
+ status: 426
114
+ });
115
+ }
116
+ let targetPath;
117
+ try {
118
+ targetPath = normalizeGatewayPath(new URL(request.url).pathname);
119
+ } catch {
120
+ return new Response(null, {
121
+ status: 400
122
+ });
123
+ }
124
+ const descriptors = descriptorsByPath.get(targetPath);
125
+ if (!descriptors) {
126
+ return new Response(null, {
127
+ status: 404
128
+ });
129
+ }
130
+ const rejection = await this.resolveUpgradeRejection(request, targetPath);
131
+ if (rejection) {
132
+ return new Response(rejection.body ?? null, {
133
+ headers: rejection.headers,
134
+ status: rejection.status
135
+ });
136
+ }
137
+ if (this.isShuttingDown) {
138
+ this.releaseUpgradeReservation();
139
+ return new Response('WebSocket server is shutting down.', {
140
+ status: 503
141
+ });
142
+ }
143
+ let response;
144
+ let socket;
145
+ try {
146
+ ({
147
+ response,
148
+ socket
149
+ } = host.upgrade(request));
150
+ } catch (error) {
151
+ this.releaseUpgradeReservation();
152
+ throw error;
153
+ }
154
+ void this.trackPendingUpgradeOperation(this.bindConnectionHandlers(socket, request, descriptors)).catch(error => {
155
+ this.unregisterSocket(this.findSocketId(socket));
156
+ this.logger.error('WebSocket gateway open lifecycle failed.', error, LIFECYCLE_LOG_CONTEXT);
157
+ socket.close(1011, 'Internal server error');
158
+ });
159
+ return response;
160
+ }
140
161
  groupDescriptorsByPath(descriptors) {
141
162
  const descriptorsByPath = new Map();
142
163
  for (const descriptor of descriptors) {
@@ -153,17 +174,35 @@ class DenoWebSocketGatewayLifecycleService {
153
174
  const state = this.createConnectionHandlerState(request, descriptors);
154
175
  this.releaseUpgradeReservation();
155
176
  this.socketRegistry.set(state.socketId, socket);
177
+ this.socketStates.set(state.socketId, state);
156
178
  this.attachConnectionListeners(state, socket, request);
157
- await this.resolveConnectionGateways(state);
158
- await this.runConnectHandlers(state, socket);
159
- await this.finalizeConnectionBinding(state, socket, request);
179
+ try {
180
+ await this.resolveConnectionGateways(state);
181
+ await this.runConnectHandlers(state, socket);
182
+ await this.finalizeConnectionBinding(state, socket, request);
183
+ if (this.isShuttingDown && socket.readyState === WEBSOCKET_OPEN_READY_STATE) {
184
+ socket.close(1001, 'Server shutting down');
185
+ await state.disconnectLifecyclePromise;
186
+ }
187
+ } finally {
188
+ if (!state.handlersReady && state.bufferedDisconnect) {
189
+ this.settleDisconnectLifecycle(state);
190
+ }
191
+ this.settleConnectLifecycle(state);
192
+ }
160
193
  }
161
194
  createConnectionHandlerState(request, descriptors) {
195
+ const connectLifecycle = createCompletionSignal();
196
+ const disconnectLifecycle = createCompletionSignal();
162
197
  return {
163
198
  bufferedDisconnect: undefined,
164
199
  bufferedMessages: [],
165
200
  bufferedMessagesStartIndex: 0,
201
+ connectLifecycleSettled: false,
202
+ connectLifecyclePromise: connectLifecycle.promise,
166
203
  descriptors,
204
+ disconnectLifecycleSettled: false,
205
+ disconnectLifecyclePromise: disconnectLifecycle.promise,
167
206
  enqueuedMessageCount: 0,
168
207
  handlerQueue: Promise.resolve(),
169
208
  handlersReady: false,
@@ -171,10 +210,26 @@ class DenoWebSocketGatewayLifecycleService {
171
210
  queuedMessages: [],
172
211
  queuedMessagesStartIndex: 0,
173
212
  request,
213
+ resolveConnectLifecycle: connectLifecycle.resolve,
214
+ resolveDisconnectLifecycle: disconnectLifecycle.resolve,
174
215
  resolved: [],
175
216
  socketId: crypto.randomUUID()
176
217
  };
177
218
  }
219
+ settleConnectLifecycle(state) {
220
+ if (state.connectLifecycleSettled) {
221
+ return;
222
+ }
223
+ state.connectLifecycleSettled = true;
224
+ state.resolveConnectLifecycle();
225
+ }
226
+ settleDisconnectLifecycle(state) {
227
+ if (state.disconnectLifecycleSettled) {
228
+ return;
229
+ }
230
+ state.disconnectLifecycleSettled = true;
231
+ state.resolveDisconnectLifecycle();
232
+ }
178
233
  attachConnectionListeners(state, socket, request) {
179
234
  socket.addEventListener('message', event => {
180
235
  if (this.closeOversizedPayload(state.socketId, socket, event.data)) {
@@ -311,6 +366,8 @@ class DenoWebSocketGatewayLifecycleService {
311
366
  await dispatchGatewayDisconnect(state.resolved, socket, disconnectEvent.code, disconnectEvent.reason, state.socketId, this.logger, LIFECYCLE_LOG_CONTEXT);
312
367
  }).catch(error => {
313
368
  this.logger.error('WebSocket gateway disconnect dispatch failed.', error, LIFECYCLE_LOG_CONTEXT);
369
+ }).finally(() => {
370
+ this.settleDisconnectLifecycle(state);
314
371
  });
315
372
  }
316
373
  async resolveConnectionGateways(state) {
@@ -361,6 +418,12 @@ class DenoWebSocketGatewayLifecycleService {
361
418
  return '';
362
419
  }
363
420
  async resolveUpgradeRejection(request, path) {
421
+ if (this.isShuttingDown) {
422
+ return {
423
+ body: 'WebSocket server is shutting down.',
424
+ status: 503
425
+ };
426
+ }
364
427
  if (!this.tryReserveUpgradeSlot()) {
365
428
  return {
366
429
  body: 'WebSocket connection limit exceeded.',
@@ -437,6 +500,13 @@ class DenoWebSocketGatewayLifecycleService {
437
500
  }
438
501
  return configured;
439
502
  }
503
+ resolveShutdownTimeoutMs() {
504
+ const configured = this.moduleOptions.shutdown?.timeoutMs;
505
+ if (typeof configured !== 'number' || !Number.isFinite(configured) || configured <= 0) {
506
+ return DEFAULT_WEBSOCKET_SHUTDOWN_TIMEOUT_MS;
507
+ }
508
+ return Math.floor(configured);
509
+ }
440
510
  async shutdown() {
441
511
  if (this.shutdownPromise) {
442
512
  await this.shutdownPromise;
@@ -446,14 +516,112 @@ class DenoWebSocketGatewayLifecycleService {
446
516
  await this.shutdownPromise;
447
517
  }
448
518
  async runShutdownLifecycle() {
519
+ this.isShuttingDown = true;
449
520
  if (hasDenoWebSocketBindingHost(this.adapter)) {
450
521
  this.adapter.configureWebSocketBinding(undefined);
451
522
  }
523
+ const shutdownTimeoutMs = this.resolveShutdownTimeoutMs();
524
+ await this.awaitPendingUpgradeOperations(shutdownTimeoutMs);
525
+ await this.closeActiveSockets(shutdownTimeoutMs);
452
526
  this.pendingUpgradeReservations = 0;
453
527
  this.socketRegistry.clear();
454
528
  this.socketRooms.clear();
529
+ this.socketStates.clear();
455
530
  this.roomSockets.clear();
456
531
  }
532
+ trackPendingUpgradeOperation(operation) {
533
+ if (typeof operation === 'function') {
534
+ return (...args) => this.trackPendingUpgradeOperation(operation(...args));
535
+ }
536
+ let trackedOperation;
537
+ trackedOperation = operation.then(() => undefined, () => undefined).finally(() => {
538
+ if (trackedOperation) {
539
+ this.pendingUpgradeOperations.delete(trackedOperation);
540
+ }
541
+ });
542
+ this.pendingUpgradeOperations.add(trackedOperation);
543
+ return operation;
544
+ }
545
+ async awaitPendingUpgradeOperations(timeoutMs) {
546
+ if (this.pendingUpgradeOperations.size === 0) {
547
+ return;
548
+ }
549
+ await new Promise((resolve, reject) => {
550
+ let settled = false;
551
+ const timeout = setTimeout(() => {
552
+ if (settled) {
553
+ return;
554
+ }
555
+ settled = true;
556
+ reject(new Error(`Timed out while waiting for in-flight Deno websocket upgrades after ${String(timeoutMs)}ms.`));
557
+ }, timeoutMs);
558
+ Promise.all([...this.pendingUpgradeOperations]).then(() => {
559
+ if (settled) {
560
+ return;
561
+ }
562
+ settled = true;
563
+ clearTimeout(timeout);
564
+ resolve();
565
+ }).catch(error => {
566
+ if (settled) {
567
+ return;
568
+ }
569
+ settled = true;
570
+ clearTimeout(timeout);
571
+ reject(error);
572
+ });
573
+ }).catch(error => {
574
+ this.logger.error(`Failed to wait for in-flight Deno websocket upgrades within ${String(timeoutMs)}ms.`, error, LIFECYCLE_LOG_CONTEXT);
575
+ });
576
+ }
577
+ async closeActiveSockets(timeoutMs) {
578
+ const activeSockets = [...this.socketRegistry.entries()];
579
+ if (activeSockets.length === 0) {
580
+ return;
581
+ }
582
+ const activeStates = activeSockets.map(([socketId]) => this.socketStates.get(socketId)).filter(state => state !== undefined);
583
+ for (const [, socket] of activeSockets) {
584
+ if (socket.readyState === WEBSOCKET_OPEN_READY_STATE) {
585
+ socket.close(1001, 'Server shutting down');
586
+ }
587
+ }
588
+ await this.awaitHandlerQueueDrain(activeStates, timeoutMs);
589
+ }
590
+ async awaitHandlerQueueDrain(states, timeoutMs) {
591
+ if (states.length === 0) {
592
+ return;
593
+ }
594
+ await new Promise((resolve, reject) => {
595
+ let settled = false;
596
+ const timeout = setTimeout(() => {
597
+ if (settled) {
598
+ return;
599
+ }
600
+ settled = true;
601
+ reject(new Error(`Timed out while closing Deno websocket connections after ${String(timeoutMs)}ms.`));
602
+ }, timeoutMs);
603
+ Promise.all(states.map(async state => {
604
+ await state.connectLifecyclePromise;
605
+ await state.disconnectLifecyclePromise;
606
+ })).then(() => {
607
+ if (settled) {
608
+ return;
609
+ }
610
+ settled = true;
611
+ clearTimeout(timeout);
612
+ resolve();
613
+ }).catch(error => {
614
+ if (settled) {
615
+ return;
616
+ }
617
+ settled = true;
618
+ clearTimeout(timeout);
619
+ reject(error);
620
+ });
621
+ }).catch(error => {
622
+ this.logger.error(`Failed to close Deno websocket connections within ${String(timeoutMs)}ms.`, error, LIFECYCLE_LOG_CONTEXT);
623
+ });
624
+ }
457
625
  joinRoom(socketId, room) {
458
626
  let rooms = this.socketRooms.get(socketId);
459
627
  if (!rooms) {
@@ -514,6 +682,7 @@ class DenoWebSocketGatewayLifecycleService {
514
682
  return;
515
683
  }
516
684
  this.socketRegistry.delete(socketId);
685
+ this.socketStates.delete(socketId);
517
686
  const rooms = this.socketRooms.get(socketId);
518
687
  if (rooms) {
519
688
  for (const room of rooms) {
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "realtime",
10
10
  "upgrade"
11
11
  ],
12
- "version": "1.0.0-beta.3",
12
+ "version": "1.0.0-beta.5",
13
13
  "private": false,
14
14
  "license": "MIT",
15
15
  "repository": {
@@ -69,17 +69,17 @@
69
69
  ],
70
70
  "dependencies": {
71
71
  "ws": "^8.18.3",
72
- "@fluojs/di": "^1.0.0-beta.4",
73
- "@fluojs/core": "^1.0.0-beta.2",
74
- "@fluojs/http": "^1.0.0-beta.3",
75
- "@fluojs/runtime": "^1.0.0-beta.4"
72
+ "@fluojs/core": "^1.0.0-beta.4",
73
+ "@fluojs/di": "^1.0.0-beta.6",
74
+ "@fluojs/http": "^1.0.0-beta.10",
75
+ "@fluojs/runtime": "^1.0.0-beta.11"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@types/ws": "^8.18.1",
79
79
  "vitest": "^3.2.4",
80
- "@fluojs/platform-bun": "^1.0.0-beta.3",
81
- "@fluojs/platform-express": "^1.0.0-beta.3",
82
- "@fluojs/platform-fastify": "^1.0.0-beta.4"
80
+ "@fluojs/platform-bun": "^1.0.0-beta.6",
81
+ "@fluojs/platform-express": "^1.0.0-beta.6",
82
+ "@fluojs/platform-fastify": "^1.0.0-beta.8"
83
83
  },
84
84
  "scripts": {
85
85
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",