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

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.ko.md CHANGED
@@ -101,7 +101,7 @@ WebSocketModule.forRoot({
101
101
  });
102
102
  ```
103
103
 
104
- 옵션을 생략하면 `@fluojs/websockets`는 동시 연결 수와 인바운드 페이로드 크기에 대해 기본 제한값을 적용합니다. 또한 server-backed Node 리스너는 `heartbeat.enabled`를 명시적으로 `false`로 두지 않는 한 heartbeat 타이머를 활성화합니다.
104
+ 옵션을 생략하면 `@fluojs/websockets`는 동시 연결 수와 인바운드 페이로드 크기에 대해 기본 제한값을 적용합니다. 또한 server-backed Node 리스너는 `heartbeat.enabled`를 명시적으로 `false`로 두지 않는 한 heartbeat 타이머를 활성화합니다. 공식 fetch-style 런타임 모듈(`@fluojs/websockets/bun`, `@fluojs/websockets/deno`, `@fluojs/websockets/cloudflare-workers`)은 애플리케이션 shutdown 시 추적 중인 websocket 클라이언트를 닫고 `shutdown.timeoutMs` 범위 안에서 `@OnDisconnect()` cleanup이 마무리될 수 있도록 bounded 기회를 제공합니다.
105
105
 
106
106
  ## 공개 API 개요
107
107
 
@@ -131,3 +131,5 @@ WebSocketModule.forRoot({
131
131
  - `packages/websockets/src/public-surface.test.ts`
132
132
  - `packages/websockets/src/node/node.test.ts`
133
133
  - `packages/websockets/src/bun/bun.test.ts`
134
+ - `packages/websockets/src/deno/deno.test.ts`
135
+ - `packages/websockets/src/cloudflare-workers/cloudflare-workers.test.ts`
package/README.md CHANGED
@@ -101,7 +101,7 @@ WebSocketModule.forRoot({
101
101
  });
102
102
  ```
103
103
 
104
- When omitted, `@fluojs/websockets` now applies bounded defaults for concurrent connections and inbound payload size. Server-backed Node listeners also enable heartbeat timers unless you explicitly set `heartbeat.enabled` to `false`.
104
+ When omitted, `@fluojs/websockets` now applies bounded defaults for concurrent connections and inbound payload size. Server-backed Node listeners also enable heartbeat timers unless you explicitly set `heartbeat.enabled` to `false`. The official fetch-style runtime modules (`@fluojs/websockets/bun`, `@fluojs/websockets/deno`, and `@fluojs/websockets/cloudflare-workers`) close tracked websocket clients during application shutdown and give `@OnDisconnect()` cleanup a bounded chance to finish within `shutdown.timeoutMs`.
105
105
 
106
106
  ## Public API Overview
107
107
 
@@ -131,3 +131,5 @@ Use the runtime subpaths when you want an explicit runtime binding instead of th
131
131
  - `packages/websockets/src/public-surface.test.ts`
132
132
  - `packages/websockets/src/node/node.test.ts`
133
133
  - `packages/websockets/src/bun/bun.test.ts`
134
+ - `packages/websockets/src/deno/deno.test.ts`
135
+ - `packages/websockets/src/cloudflare-workers/cloudflare-workers.test.ts`
@@ -12,11 +12,14 @@ export declare class BunWebSocketGatewayLifecycleService implements OnApplicatio
12
12
  private readonly logger;
13
13
  private readonly adapter;
14
14
  private readonly moduleOptions;
15
+ private isShuttingDown;
16
+ private readonly pendingUpgradeOperations;
15
17
  private pendingUpgradeReservations;
16
18
  private readonly roomSockets;
17
19
  private shutdownPromise;
18
20
  private readonly socketRegistry;
19
21
  private readonly socketRooms;
22
+ private readonly socketStates;
20
23
  constructor(runtimeContainer: Container, compiledModules: readonly CompiledModule[], logger: ApplicationLogger, adapter: HttpApplicationAdapter, moduleOptions: WebSocketModuleOptions);
21
24
  onApplicationBootstrap(): Promise<void>;
22
25
  private assertNoServerBackedGatewayOptIn;
@@ -27,6 +30,9 @@ export declare class BunWebSocketGatewayLifecycleService implements OnApplicatio
27
30
  private handleUpgradeRequest;
28
31
  private bindConnectionHandlers;
29
32
  private createConnectionHandlerState;
33
+ private settleOpenRegistration;
34
+ private settleConnectLifecycle;
35
+ private settleDisconnectLifecycle;
30
36
  private getBufferedMessageCount;
31
37
  private getQueuedMessageCount;
32
38
  private maybeCompactBufferedMessages;
@@ -52,8 +58,13 @@ export declare class BunWebSocketGatewayLifecycleService implements OnApplicatio
52
58
  private releaseUpgradeReservation;
53
59
  private resolveMaxPayloadBytes;
54
60
  private resolveIdleTimeoutSeconds;
61
+ private resolveShutdownTimeoutMs;
55
62
  private shutdown;
56
63
  private runShutdownLifecycle;
64
+ private trackPendingUpgradeOperation;
65
+ private awaitPendingUpgradeOperations;
66
+ private closeActiveSockets;
67
+ private awaitHandlerQueueDrain;
57
68
  joinRoom(socketId: string, room: string): void;
58
69
  leaveRoom(socketId: string, room: string): void;
59
70
  broadcastToRoom(room: string, event: string, data: unknown): void;
@@ -1 +1 @@
1
- {"version":3,"file":"bun-service.d.ts","sourceRoot":"","sources":["../../src/bun/bun-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AASzI,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAa3D,OAAO,KAAK,EAGV,oBAAoB,EACrB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AAyG7D;;GAEG;AACH,qBACa,mCACX,YAAW,sBAAsB,EAAE,qBAAqB,EAAE,eAAe,EAAE,oBAAoB;IAS7F,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAXhC,OAAO,CAAC,0BAA0B,CAAK;IACvC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;IAC9D,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAwD;IACvF,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;gBAG3C,gBAAgB,EAAE,SAAS,EAC3B,eAAe,EAAE,SAAS,cAAc,EAAE,EAC1C,MAAM,EAAE,iBAAiB,EACzB,OAAO,EAAE,sBAAsB,EAC/B,aAAa,EAAE,sBAAsB;IAGlD,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB7C,OAAO,CAAC,gCAAgC;IAclC,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,OAAO,CAAC,aAAa;IAiCrB,OAAO,CAAC,sBAAsB;YAmBhB,oBAAoB;YAkDpB,sBAAsB;IAWpC,OAAO,CAAC,4BAA4B;IAqBpC,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,4BAA4B;IASpC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,0BAA0B;IASlC,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,mBAAmB;IAe3B,OAAO,CAAC,iBAAiB;IAYzB,OAAO,CAAC,qBAAqB;IA0C7B,OAAO,CAAC,sBAAsB;YAwDhB,iBAAiB;IAmB/B,OAAO,CAAC,yBAAyB;YAsBnB,yBAAyB;YAczB,kBAAkB;YASlB,yBAAyB;IASvC,OAAO,CAAC,8BAA8B;IAoBtC,OAAO,CAAC,wBAAwB;YAUlB,uBAAuB;IAmDrC,OAAO,CAAC,qBAAqB;IAmB7B,OAAO,CAAC,yBAAyB;IAUjC,OAAO,CAAC,8BAA8B;IAItC,OAAO,CAAC,qBAAqB;IAS7B,OAAO,CAAC,yBAAyB;IAMjC,OAAO,CAAC,sBAAsB;IAU9B,OAAO,CAAC,yBAAyB;YAcnB,QAAQ;YAUR,oBAAoB;IAYlC,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAmB9C,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAc/C,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI;IA4BjE,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC;IAU/C,OAAO,CAAC,gBAAgB;CAgBzB"}
1
+ {"version":3,"file":"bun-service.d.ts","sourceRoot":"","sources":["../../src/bun/bun-service.ts"],"names":[],"mappings":"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"}
@@ -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
  function hasBunWebSocketBindingHost(adapter) {
16
17
  return 'configureWebSocketBinding' in adapter && typeof adapter.configureWebSocketBinding === 'function';
@@ -40,6 +41,16 @@ function resolveMessageByteLength(message) {
40
41
  function isWebSocketUpgradeRequest(request) {
41
42
  return request.headers.get('upgrade')?.toLowerCase() === 'websocket';
42
43
  }
44
+ function createCompletionSignal() {
45
+ let resolve;
46
+ const promise = new Promise(res => {
47
+ resolve = res;
48
+ });
49
+ return {
50
+ promise,
51
+ resolve
52
+ };
53
+ }
43
54
 
44
55
  /**
45
56
  * Boots Bun-backed websocket gateways and manages their room lifecycle state.
@@ -49,11 +60,14 @@ class BunWebSocketGatewayLifecycleService {
49
60
  static {
50
61
  [_BunWebSocketGatewayL, _initClass] = _applyDecs(this, [Inject(RUNTIME_CONTAINER, COMPILED_MODULES, APPLICATION_LOGGER, HTTP_APPLICATION_ADAPTER, WEBSOCKET_OPTIONS_INTERNAL)], []).c;
51
62
  }
63
+ isShuttingDown = false;
64
+ pendingUpgradeOperations = new Set();
52
65
  pendingUpgradeReservations = 0;
53
66
  roomSockets = new Map();
54
67
  shutdownPromise;
55
68
  socketRegistry = new Map();
56
69
  socketRooms = new Map();
70
+ socketStates = new Map();
57
71
  constructor(runtimeContainer, compiledModules, logger, adapter, moduleOptions) {
58
72
  this.runtimeContainer = runtimeContainer;
59
73
  this.compiledModules = compiledModules;
@@ -90,7 +104,7 @@ class BunWebSocketGatewayLifecycleService {
90
104
  createBinding(descriptors) {
91
105
  const descriptorsByPath = this.groupDescriptorsByPath(descriptors);
92
106
  return {
93
- fetch: (request, server) => this.handleUpgradeRequest(request, server, descriptorsByPath),
107
+ fetch: (request, server) => this.trackPendingUpgradeOperation(this.handleUpgradeRequest(request, server, descriptorsByPath)),
94
108
  websocket: {
95
109
  backpressureLimit: this.resolveBackpressureLimit(),
96
110
  close: (socket, code, reason) => {
@@ -108,7 +122,7 @@ class BunWebSocketGatewayLifecycleService {
108
122
  this.handleSocketMessage(socket, message);
109
123
  },
110
124
  open: socket => {
111
- void this.bindConnectionHandlers(socket).catch(error => {
125
+ void this.trackPendingUpgradeOperation(this.bindConnectionHandlers(socket)).catch(error => {
112
126
  this.unregisterSocket(socket.data.state.socketId);
113
127
  this.logger.error('WebSocket gateway open lifecycle failed.', error, LIFECYCLE_LOG_CONTEXT);
114
128
  socket.close(1011, 'Internal server error');
@@ -155,7 +169,14 @@ class BunWebSocketGatewayLifecycleService {
155
169
  status: rejection.status
156
170
  });
157
171
  }
172
+ if (this.isShuttingDown) {
173
+ this.releaseUpgradeReservation();
174
+ return new Response('WebSocket server is shutting down.', {
175
+ status: 503
176
+ });
177
+ }
158
178
  const state = this.createConnectionHandlerState(request, [...descriptors]);
179
+ void this.trackPendingUpgradeOperation(state.openRegistrationPromise);
159
180
  let upgraded = false;
160
181
  try {
161
182
  upgraded = server.upgrade(request, {
@@ -164,10 +185,12 @@ class BunWebSocketGatewayLifecycleService {
164
185
  }
165
186
  });
166
187
  } catch (error) {
188
+ this.settleOpenRegistration(state);
167
189
  this.releaseUpgradeReservation();
168
190
  throw error;
169
191
  }
170
192
  if (!upgraded) {
193
+ this.settleOpenRegistration(state);
171
194
  this.releaseUpgradeReservation();
172
195
  return new Response(null, {
173
196
  status: 400
@@ -181,27 +204,73 @@ class BunWebSocketGatewayLifecycleService {
181
204
  } = socket.data;
182
205
  this.releaseUpgradeReservation();
183
206
  this.socketRegistry.set(state.socketId, socket);
184
- await this.resolveConnectionGateways(state);
185
- await this.runConnectHandlers(state, socket);
186
- await this.finalizeConnectionBinding(state, socket);
207
+ this.socketStates.set(state.socketId, state);
208
+ this.settleOpenRegistration(state);
209
+ try {
210
+ await this.resolveConnectionGateways(state);
211
+ await this.runConnectHandlers(state, socket);
212
+ await this.finalizeConnectionBinding(state, socket);
213
+ if (this.isShuttingDown && socket.readyState === 1) {
214
+ socket.close(1001, 'Server shutting down');
215
+ await state.disconnectLifecyclePromise;
216
+ }
217
+ } finally {
218
+ if (!state.handlersReady && state.bufferedDisconnect) {
219
+ this.settleDisconnectLifecycle(state);
220
+ }
221
+ this.settleConnectLifecycle(state);
222
+ }
187
223
  }
188
224
  createConnectionHandlerState(request, descriptors) {
225
+ const connectLifecycle = createCompletionSignal();
226
+ const disconnectLifecycle = createCompletionSignal();
227
+ const openRegistration = createCompletionSignal();
189
228
  return {
190
229
  bufferedDisconnect: undefined,
191
230
  bufferedMessages: [],
192
231
  bufferedMessagesStartIndex: 0,
232
+ connectLifecycleSettled: false,
233
+ connectLifecyclePromise: connectLifecycle.promise,
193
234
  descriptors,
235
+ disconnectLifecycleSettled: false,
236
+ disconnectLifecyclePromise: disconnectLifecycle.promise,
194
237
  enqueuedMessageCount: 0,
195
238
  handlerQueue: Promise.resolve(),
196
239
  handlersReady: false,
240
+ openRegistrationSettled: false,
241
+ openRegistrationPromise: openRegistration.promise,
197
242
  processingMessageQueue: false,
198
243
  queuedMessages: [],
199
244
  queuedMessagesStartIndex: 0,
200
245
  request,
246
+ resolveOpenRegistration: openRegistration.resolve,
247
+ resolveConnectLifecycle: connectLifecycle.resolve,
248
+ resolveDisconnectLifecycle: disconnectLifecycle.resolve,
201
249
  resolved: [],
202
250
  socketId: crypto.randomUUID()
203
251
  };
204
252
  }
253
+ settleOpenRegistration(state) {
254
+ if (state.openRegistrationSettled) {
255
+ return;
256
+ }
257
+ state.openRegistrationSettled = true;
258
+ state.resolveOpenRegistration();
259
+ }
260
+ settleConnectLifecycle(state) {
261
+ if (state.connectLifecycleSettled) {
262
+ return;
263
+ }
264
+ state.connectLifecycleSettled = true;
265
+ state.resolveConnectLifecycle();
266
+ }
267
+ settleDisconnectLifecycle(state) {
268
+ if (state.disconnectLifecycleSettled) {
269
+ return;
270
+ }
271
+ state.disconnectLifecycleSettled = true;
272
+ state.resolveDisconnectLifecycle();
273
+ }
205
274
  getBufferedMessageCount(state) {
206
275
  return state.bufferedMessages.length - state.bufferedMessagesStartIndex;
207
276
  }
@@ -330,6 +399,8 @@ class BunWebSocketGatewayLifecycleService {
330
399
  await dispatchGatewayDisconnect(state.resolved, socket, disconnectEvent.code, disconnectEvent.reason, state.socketId, this.logger, LIFECYCLE_LOG_CONTEXT);
331
400
  }).catch(error => {
332
401
  this.logger.error('WebSocket gateway disconnect dispatch failed.', error, LIFECYCLE_LOG_CONTEXT);
402
+ }).finally(() => {
403
+ this.settleDisconnectLifecycle(state);
333
404
  });
334
405
  }
335
406
  async resolveConnectionGateways(state) {
@@ -379,6 +450,12 @@ class BunWebSocketGatewayLifecycleService {
379
450
  return configured;
380
451
  }
381
452
  async resolveUpgradeRejection(request, path) {
453
+ if (this.isShuttingDown) {
454
+ return {
455
+ body: 'WebSocket server is shutting down.',
456
+ status: 503
457
+ };
458
+ }
382
459
  if (!this.tryReserveUpgradeSlot()) {
383
460
  return {
384
461
  body: 'WebSocket connection limit exceeded.',
@@ -465,6 +542,13 @@ class BunWebSocketGatewayLifecycleService {
465
542
  }
466
543
  return Math.max(1, Math.ceil(configured / 1000));
467
544
  }
545
+ resolveShutdownTimeoutMs() {
546
+ const configured = this.moduleOptions.shutdown?.timeoutMs;
547
+ if (typeof configured !== 'number' || !Number.isFinite(configured) || configured <= 0) {
548
+ return DEFAULT_WEBSOCKET_SHUTDOWN_TIMEOUT_MS;
549
+ }
550
+ return Math.floor(configured);
551
+ }
468
552
  async shutdown() {
469
553
  if (this.shutdownPromise) {
470
554
  await this.shutdownPromise;
@@ -474,15 +558,110 @@ class BunWebSocketGatewayLifecycleService {
474
558
  await this.shutdownPromise;
475
559
  }
476
560
  async runShutdownLifecycle() {
561
+ this.isShuttingDown = true;
477
562
  if (hasBunWebSocketBindingHost(this.adapter)) {
478
563
  const bunAdapter = this.adapter;
479
564
  bunAdapter.configureWebSocketBinding(undefined);
480
565
  }
566
+ const shutdownTimeoutMs = this.resolveShutdownTimeoutMs();
567
+ await this.awaitPendingUpgradeOperations(shutdownTimeoutMs);
568
+ await this.closeActiveSockets(shutdownTimeoutMs);
481
569
  this.pendingUpgradeReservations = 0;
482
570
  this.socketRegistry.clear();
483
571
  this.socketRooms.clear();
572
+ this.socketStates.clear();
484
573
  this.roomSockets.clear();
485
574
  }
575
+ trackPendingUpgradeOperation(operation) {
576
+ let trackedOperation;
577
+ trackedOperation = operation.then(() => undefined, () => undefined).finally(() => {
578
+ if (trackedOperation) {
579
+ this.pendingUpgradeOperations.delete(trackedOperation);
580
+ }
581
+ });
582
+ this.pendingUpgradeOperations.add(trackedOperation);
583
+ return operation;
584
+ }
585
+ async awaitPendingUpgradeOperations(timeoutMs) {
586
+ if (this.pendingUpgradeOperations.size === 0) {
587
+ return;
588
+ }
589
+ await new Promise((resolve, reject) => {
590
+ let settled = false;
591
+ const timeout = setTimeout(() => {
592
+ if (settled) {
593
+ return;
594
+ }
595
+ settled = true;
596
+ reject(new Error(`Timed out while waiting for in-flight Bun websocket upgrades after ${String(timeoutMs)}ms.`));
597
+ }, timeoutMs);
598
+ Promise.all([...this.pendingUpgradeOperations]).then(() => {
599
+ if (settled) {
600
+ return;
601
+ }
602
+ settled = true;
603
+ clearTimeout(timeout);
604
+ resolve();
605
+ }).catch(error => {
606
+ if (settled) {
607
+ return;
608
+ }
609
+ settled = true;
610
+ clearTimeout(timeout);
611
+ reject(error);
612
+ });
613
+ }).catch(error => {
614
+ this.logger.error(`Failed to wait for in-flight Bun websocket upgrades within ${String(timeoutMs)}ms.`, error, LIFECYCLE_LOG_CONTEXT);
615
+ });
616
+ }
617
+ async closeActiveSockets(timeoutMs) {
618
+ const activeSockets = [...this.socketRegistry.entries()];
619
+ if (activeSockets.length === 0) {
620
+ return;
621
+ }
622
+ const activeStates = activeSockets.map(([socketId]) => this.socketStates.get(socketId)).filter(state => state !== undefined);
623
+ for (const [, socket] of activeSockets) {
624
+ if (socket.readyState === 1) {
625
+ socket.close(1001, 'Server shutting down');
626
+ }
627
+ }
628
+ await this.awaitHandlerQueueDrain(activeStates, timeoutMs);
629
+ }
630
+ async awaitHandlerQueueDrain(states, timeoutMs) {
631
+ if (states.length === 0) {
632
+ return;
633
+ }
634
+ await new Promise((resolve, reject) => {
635
+ let settled = false;
636
+ const timeout = setTimeout(() => {
637
+ if (settled) {
638
+ return;
639
+ }
640
+ settled = true;
641
+ reject(new Error(`Timed out while closing Bun websocket connections after ${String(timeoutMs)}ms.`));
642
+ }, timeoutMs);
643
+ Promise.all(states.map(async state => {
644
+ await state.connectLifecyclePromise;
645
+ await state.disconnectLifecyclePromise;
646
+ })).then(() => {
647
+ if (settled) {
648
+ return;
649
+ }
650
+ settled = true;
651
+ clearTimeout(timeout);
652
+ resolve();
653
+ }).catch(error => {
654
+ if (settled) {
655
+ return;
656
+ }
657
+ settled = true;
658
+ clearTimeout(timeout);
659
+ reject(error);
660
+ });
661
+ }).catch(error => {
662
+ this.logger.error(`Failed to close Bun websocket connections within ${String(timeoutMs)}ms.`, error, LIFECYCLE_LOG_CONTEXT);
663
+ });
664
+ }
486
665
  joinRoom(socketId, room) {
487
666
  let rooms = this.socketRooms.get(socketId);
488
667
  if (!rooms) {
@@ -539,6 +718,7 @@ class BunWebSocketGatewayLifecycleService {
539
718
  }
540
719
  unregisterSocket(socketId) {
541
720
  this.socketRegistry.delete(socketId);
721
+ this.socketStates.delete(socketId);
542
722
  const rooms = this.socketRooms.get(socketId);
543
723
  if (rooms) {
544
724
  for (const room of rooms) {
@@ -12,20 +12,26 @@ export declare class CloudflareWorkersWebSocketGatewayLifecycleService implement
12
12
  private readonly logger;
13
13
  private readonly adapter;
14
14
  private readonly moduleOptions;
15
+ private isShuttingDown;
16
+ private readonly pendingUpgradeOperations;
15
17
  private pendingUpgradeReservations;
16
18
  private readonly roomSockets;
17
19
  private shutdownPromise;
18
20
  private readonly socketRegistry;
19
21
  private readonly socketRooms;
22
+ private readonly socketStates;
20
23
  constructor(runtimeContainer: Container, compiledModules: readonly CompiledModule[], logger: ApplicationLogger, adapter: HttpApplicationAdapter, moduleOptions: WebSocketModuleOptions);
21
24
  onApplicationBootstrap(): Promise<void>;
22
25
  private assertNoServerBackedGatewayOptIn;
23
26
  onApplicationShutdown(): Promise<void>;
24
27
  onModuleDestroy(): Promise<void>;
25
28
  private createBinding;
29
+ private handleUpgradeRequest;
26
30
  private groupDescriptorsByPath;
27
31
  private bindConnectionHandlers;
28
32
  private createConnectionHandlerState;
33
+ private settleConnectLifecycle;
34
+ private settleDisconnectLifecycle;
29
35
  private attachConnectionListeners;
30
36
  private getBufferedMessageCount;
31
37
  private getQueuedMessageCount;
@@ -50,8 +56,13 @@ export declare class CloudflareWorkersWebSocketGatewayLifecycleService implement
50
56
  private tryReserveUpgradeSlot;
51
57
  private releaseUpgradeReservation;
52
58
  private resolveMaxPayloadBytes;
59
+ private resolveShutdownTimeoutMs;
53
60
  private shutdown;
54
61
  private runShutdownLifecycle;
62
+ private trackPendingUpgradeOperation;
63
+ private awaitPendingUpgradeOperations;
64
+ private closeActiveSockets;
65
+ private awaitHandlerQueueDrain;
55
66
  joinRoom(socketId: string, room: string): void;
56
67
  leaveRoom(socketId: string, room: string): void;
57
68
  broadcastToRoom(room: string, event: string, data: unknown): void;
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare-workers-service.d.ts","sourceRoot":"","sources":["../../src/cloudflare-workers/cloudflare-workers-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEzI,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAa3D,OAAO,KAAK,EAA8B,oBAAoB,EAA6B,MAAM,aAAa,CAAC;AAC/G,OAAO,KAAK,EAKV,sBAAsB,EACvB,MAAM,+BAA+B,CAAC;AA0GvC;;GAEG;AACH,qBACa,iDACX,YAAW,sBAAsB,EAAE,qBAAqB,EAAE,eAAe,EAAE,oBAAoB;IAS7F,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAXhC,OAAO,CAAC,0BAA0B,CAAK;IACvC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;IAC9D,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAgD;IAC/E,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;gBAG3C,gBAAgB,EAAE,SAAS,EAC3B,eAAe,EAAE,SAAS,cAAc,EAAE,EAC1C,MAAM,EAAE,iBAAiB,EACzB,OAAO,EAAE,sBAAsB,EAC/B,aAAa,EAAE,sBAAsB;IAGlD,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB7C,OAAO,CAAC,gCAAgC;IAclC,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,OAAO,CAAC,aAAa;IAqDrB,OAAO,CAAC,sBAAsB;YAmBhB,sBAAsB;IAgBpC,OAAO,CAAC,4BAA4B;IAqBpC,OAAO,CAAC,yBAAyB;IAyCjC,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,4BAA4B;IASpC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,0BAA0B;IASlC,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,qBAAqB;IA0C7B,OAAO,CAAC,sBAAsB;YAyDhB,iBAAiB;YAqBjB,gBAAgB;IAU9B,OAAO,CAAC,yBAAyB;YAsBnB,yBAAyB;YAczB,kBAAkB;YAgBlB,yBAAyB;YAUzB,8BAA8B;IAqB5C,OAAO,CAAC,YAAY;YAUN,uBAAuB;IAmDrC,OAAO,CAAC,qBAAqB;IAmB7B,OAAO,CAAC,yBAAyB;IAUjC,OAAO,CAAC,8BAA8B;IAItC,OAAO,CAAC,qBAAqB;IAS7B,OAAO,CAAC,yBAAyB;IAMjC,OAAO,CAAC,sBAAsB;YAUhB,QAAQ;YAUR,oBAAoB;IAWlC,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAmB9C,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAc/C,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI;IA4BjE,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC;IAU/C,OAAO,CAAC,gBAAgB;CAoBzB"}
1
+ {"version":3,"file":"cloudflare-workers-service.d.ts","sourceRoot":"","sources":["../../src/cloudflare-workers/cloudflare-workers-service.ts"],"names":[],"mappings":"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"}
@@ -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 hasCloudflareWorkerWebSocketBindingHost(adapter) {
@@ -44,6 +45,16 @@ function resolveMessageByteLength(message) {
44
45
  }
45
46
  return message.byteLength;
46
47
  }
48
+ function createCompletionSignal() {
49
+ let resolve;
50
+ const promise = new Promise(res => {
51
+ resolve = res;
52
+ });
53
+ return {
54
+ promise,
55
+ resolve
56
+ };
57
+ }
47
58
 
48
59
  /**
49
60
  * Boots Cloudflare Workers websocket gateways and manages their room lifecycle state.
@@ -53,11 +64,14 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
53
64
  static {
54
65
  [_CloudflareWorkersWeb, _initClass] = _applyDecs(this, [Inject(RUNTIME_CONTAINER, COMPILED_MODULES, APPLICATION_LOGGER, HTTP_APPLICATION_ADAPTER, WEBSOCKET_OPTIONS_INTERNAL)], []).c;
55
66
  }
67
+ isShuttingDown = false;
68
+ pendingUpgradeOperations = new Set();
56
69
  pendingUpgradeReservations = 0;
57
70
  roomSockets = new Map();
58
71
  shutdownPromise;
59
72
  socketRegistry = new Map();
60
73
  socketRooms = new Map();
74
+ socketStates = new Map();
61
75
  constructor(runtimeContainer, compiledModules, logger, adapter, moduleOptions) {
62
76
  this.runtimeContainer = runtimeContainer;
63
77
  this.compiledModules = compiledModules;
@@ -93,54 +107,61 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
93
107
  createBinding(descriptors) {
94
108
  const descriptorsByPath = this.groupDescriptorsByPath(descriptors);
95
109
  return {
96
- fetch: async (request, host) => {
97
- if (!isWebSocketUpgradeRequest(request)) {
98
- return new Response(null, {
99
- status: 426
100
- });
101
- }
102
- let targetPath;
103
- try {
104
- targetPath = normalizeGatewayPath(new URL(request.url).pathname);
105
- } catch {
106
- return new Response(null, {
107
- status: 400
108
- });
109
- }
110
- const matchedDescriptors = descriptorsByPath.get(targetPath);
111
- if (!matchedDescriptors) {
112
- return new Response(null, {
113
- status: 404
114
- });
115
- }
116
- const rejection = await this.resolveUpgradeRejection(request, targetPath);
117
- if (rejection) {
118
- return new Response(rejection.body ?? null, {
119
- headers: rejection.headers,
120
- status: rejection.status
121
- });
122
- }
123
- let response;
124
- let serverSocket;
125
- try {
126
- ({
127
- response,
128
- serverSocket
129
- } = host.upgrade(request));
130
- } catch (error) {
131
- this.releaseUpgradeReservation();
132
- throw error;
133
- }
134
- serverSocket.accept();
135
- void this.bindConnectionHandlers(serverSocket, request, matchedDescriptors).catch(error => {
136
- this.unregisterSocket(this.findSocketId(serverSocket));
137
- this.logger.error('WebSocket gateway open lifecycle failed.', error, LIFECYCLE_LOG_CONTEXT);
138
- serverSocket.close(1011, 'Internal server error');
139
- });
140
- return response;
141
- }
110
+ fetch: (request, host) => this.trackPendingUpgradeOperation(this.handleUpgradeRequest(request, host, descriptorsByPath))
142
111
  };
143
112
  }
113
+ async handleUpgradeRequest(request, host, descriptorsByPath) {
114
+ if (!isWebSocketUpgradeRequest(request)) {
115
+ return new Response(null, {
116
+ status: 426
117
+ });
118
+ }
119
+ let targetPath;
120
+ try {
121
+ targetPath = normalizeGatewayPath(new URL(request.url).pathname);
122
+ } catch {
123
+ return new Response(null, {
124
+ status: 400
125
+ });
126
+ }
127
+ const matchedDescriptors = descriptorsByPath.get(targetPath);
128
+ if (!matchedDescriptors) {
129
+ return new Response(null, {
130
+ status: 404
131
+ });
132
+ }
133
+ const rejection = await this.resolveUpgradeRejection(request, targetPath);
134
+ if (rejection) {
135
+ return new Response(rejection.body ?? null, {
136
+ headers: rejection.headers,
137
+ status: rejection.status
138
+ });
139
+ }
140
+ if (this.isShuttingDown) {
141
+ this.releaseUpgradeReservation();
142
+ return new Response('WebSocket server is shutting down.', {
143
+ status: 503
144
+ });
145
+ }
146
+ let response;
147
+ let serverSocket;
148
+ try {
149
+ ({
150
+ response,
151
+ serverSocket
152
+ } = host.upgrade(request));
153
+ } catch (error) {
154
+ this.releaseUpgradeReservation();
155
+ throw error;
156
+ }
157
+ serverSocket.accept();
158
+ void this.trackPendingUpgradeOperation(this.bindConnectionHandlers(serverSocket, request, matchedDescriptors)).catch(error => {
159
+ this.unregisterSocket(this.findSocketId(serverSocket));
160
+ this.logger.error('WebSocket gateway open lifecycle failed.', error, LIFECYCLE_LOG_CONTEXT);
161
+ serverSocket.close(1011, 'Internal server error');
162
+ });
163
+ return response;
164
+ }
144
165
  groupDescriptorsByPath(descriptors) {
145
166
  const descriptorsByPath = new Map();
146
167
  for (const descriptor of descriptors) {
@@ -157,17 +178,35 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
157
178
  const state = this.createConnectionHandlerState(request, descriptors);
158
179
  this.releaseUpgradeReservation();
159
180
  this.socketRegistry.set(state.socketId, socket);
181
+ this.socketStates.set(state.socketId, state);
160
182
  this.attachConnectionListeners(state, socket, request);
161
- await this.resolveConnectionGateways(state);
162
- await this.runConnectHandlers(state, socket);
163
- await this.finalizeConnectionBinding(state, socket, request);
183
+ try {
184
+ await this.resolveConnectionGateways(state);
185
+ await this.runConnectHandlers(state, socket);
186
+ await this.finalizeConnectionBinding(state, socket, request);
187
+ if (this.isShuttingDown && socket.readyState === WEBSOCKET_OPEN_READY_STATE) {
188
+ socket.close(1001, 'Server shutting down');
189
+ await state.disconnectLifecyclePromise;
190
+ }
191
+ } finally {
192
+ if (!state.handlersReady && state.bufferedDisconnect) {
193
+ this.settleDisconnectLifecycle(state);
194
+ }
195
+ this.settleConnectLifecycle(state);
196
+ }
164
197
  }
165
198
  createConnectionHandlerState(request, descriptors) {
199
+ const connectLifecycle = createCompletionSignal();
200
+ const disconnectLifecycle = createCompletionSignal();
166
201
  return {
167
202
  bufferedDisconnect: undefined,
168
203
  bufferedMessages: [],
169
204
  bufferedMessagesStartIndex: 0,
205
+ connectLifecycleSettled: false,
206
+ connectLifecyclePromise: connectLifecycle.promise,
170
207
  descriptors,
208
+ disconnectLifecycleSettled: false,
209
+ disconnectLifecyclePromise: disconnectLifecycle.promise,
171
210
  enqueuedMessageCount: 0,
172
211
  handlerQueue: Promise.resolve(),
173
212
  handlersReady: false,
@@ -175,10 +214,26 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
175
214
  queuedMessages: [],
176
215
  queuedMessagesStartIndex: 0,
177
216
  request,
217
+ resolveConnectLifecycle: connectLifecycle.resolve,
218
+ resolveDisconnectLifecycle: disconnectLifecycle.resolve,
178
219
  resolved: [],
179
220
  socketId: crypto.randomUUID()
180
221
  };
181
222
  }
223
+ settleConnectLifecycle(state) {
224
+ if (state.connectLifecycleSettled) {
225
+ return;
226
+ }
227
+ state.connectLifecycleSettled = true;
228
+ state.resolveConnectLifecycle();
229
+ }
230
+ settleDisconnectLifecycle(state) {
231
+ if (state.disconnectLifecycleSettled) {
232
+ return;
233
+ }
234
+ state.disconnectLifecycleSettled = true;
235
+ state.resolveDisconnectLifecycle();
236
+ }
182
237
  attachConnectionListeners(state, socket, request) {
183
238
  socket.addEventListener('message', event => {
184
239
  if (this.closeOversizedPayload(state.socketId, socket, event.data)) {
@@ -316,6 +371,8 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
316
371
  await dispatchGatewayDisconnect(state.resolved, socket, disconnectEvent.code, disconnectEvent.reason, state.socketId, this.logger, LIFECYCLE_LOG_CONTEXT);
317
372
  }).catch(error => {
318
373
  this.logger.error('WebSocket gateway disconnect dispatch failed.', error, LIFECYCLE_LOG_CONTEXT);
374
+ }).finally(() => {
375
+ this.settleDisconnectLifecycle(state);
319
376
  });
320
377
  }
321
378
  async resolveConnectionGateways(state) {
@@ -366,6 +423,12 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
366
423
  return '';
367
424
  }
368
425
  async resolveUpgradeRejection(request, path) {
426
+ if (this.isShuttingDown) {
427
+ return {
428
+ body: 'WebSocket server is shutting down.',
429
+ status: 503
430
+ };
431
+ }
369
432
  if (!this.tryReserveUpgradeSlot()) {
370
433
  return {
371
434
  body: 'WebSocket connection limit exceeded.',
@@ -442,6 +505,13 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
442
505
  }
443
506
  return configured;
444
507
  }
508
+ resolveShutdownTimeoutMs() {
509
+ const configured = this.moduleOptions.shutdown?.timeoutMs;
510
+ if (typeof configured !== 'number' || !Number.isFinite(configured) || configured <= 0) {
511
+ return DEFAULT_WEBSOCKET_SHUTDOWN_TIMEOUT_MS;
512
+ }
513
+ return Math.floor(configured);
514
+ }
445
515
  async shutdown() {
446
516
  if (this.shutdownPromise) {
447
517
  await this.shutdownPromise;
@@ -451,14 +521,112 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
451
521
  await this.shutdownPromise;
452
522
  }
453
523
  async runShutdownLifecycle() {
524
+ this.isShuttingDown = true;
454
525
  if (hasCloudflareWorkerWebSocketBindingHost(this.adapter)) {
455
526
  this.adapter.configureWebSocketBinding(undefined);
456
527
  }
528
+ const shutdownTimeoutMs = this.resolveShutdownTimeoutMs();
529
+ await this.awaitPendingUpgradeOperations(shutdownTimeoutMs);
530
+ await this.closeActiveSockets(shutdownTimeoutMs);
457
531
  this.pendingUpgradeReservations = 0;
458
532
  this.socketRegistry.clear();
459
533
  this.socketRooms.clear();
534
+ this.socketStates.clear();
460
535
  this.roomSockets.clear();
461
536
  }
537
+ trackPendingUpgradeOperation(operation) {
538
+ if (typeof operation === 'function') {
539
+ return (...args) => this.trackPendingUpgradeOperation(operation(...args));
540
+ }
541
+ let trackedOperation;
542
+ trackedOperation = operation.then(() => undefined, () => undefined).finally(() => {
543
+ if (trackedOperation) {
544
+ this.pendingUpgradeOperations.delete(trackedOperation);
545
+ }
546
+ });
547
+ this.pendingUpgradeOperations.add(trackedOperation);
548
+ return operation;
549
+ }
550
+ async awaitPendingUpgradeOperations(timeoutMs) {
551
+ if (this.pendingUpgradeOperations.size === 0) {
552
+ return;
553
+ }
554
+ await new Promise((resolve, reject) => {
555
+ let settled = false;
556
+ const timeout = setTimeout(() => {
557
+ if (settled) {
558
+ return;
559
+ }
560
+ settled = true;
561
+ reject(new Error(`Timed out while waiting for in-flight Cloudflare Worker websocket upgrades after ${String(timeoutMs)}ms.`));
562
+ }, timeoutMs);
563
+ Promise.all([...this.pendingUpgradeOperations]).then(() => {
564
+ if (settled) {
565
+ return;
566
+ }
567
+ settled = true;
568
+ clearTimeout(timeout);
569
+ resolve();
570
+ }).catch(error => {
571
+ if (settled) {
572
+ return;
573
+ }
574
+ settled = true;
575
+ clearTimeout(timeout);
576
+ reject(error);
577
+ });
578
+ }).catch(error => {
579
+ this.logger.error(`Failed to wait for in-flight Cloudflare Worker websocket upgrades within ${String(timeoutMs)}ms.`, error, LIFECYCLE_LOG_CONTEXT);
580
+ });
581
+ }
582
+ async closeActiveSockets(timeoutMs) {
583
+ const activeSockets = [...this.socketRegistry.entries()];
584
+ if (activeSockets.length === 0) {
585
+ return;
586
+ }
587
+ const activeStates = activeSockets.map(([socketId]) => this.socketStates.get(socketId)).filter(state => state !== undefined);
588
+ for (const [, socket] of activeSockets) {
589
+ if (socket.readyState === WEBSOCKET_OPEN_READY_STATE) {
590
+ socket.close(1001, 'Server shutting down');
591
+ }
592
+ }
593
+ await this.awaitHandlerQueueDrain(activeStates, timeoutMs);
594
+ }
595
+ async awaitHandlerQueueDrain(states, timeoutMs) {
596
+ if (states.length === 0) {
597
+ return;
598
+ }
599
+ await new Promise((resolve, reject) => {
600
+ let settled = false;
601
+ const timeout = setTimeout(() => {
602
+ if (settled) {
603
+ return;
604
+ }
605
+ settled = true;
606
+ reject(new Error(`Timed out while closing Cloudflare Worker websocket connections after ${String(timeoutMs)}ms.`));
607
+ }, timeoutMs);
608
+ Promise.all(states.map(async state => {
609
+ await state.connectLifecyclePromise;
610
+ await state.disconnectLifecyclePromise;
611
+ })).then(() => {
612
+ if (settled) {
613
+ return;
614
+ }
615
+ settled = true;
616
+ clearTimeout(timeout);
617
+ resolve();
618
+ }).catch(error => {
619
+ if (settled) {
620
+ return;
621
+ }
622
+ settled = true;
623
+ clearTimeout(timeout);
624
+ reject(error);
625
+ });
626
+ }).catch(error => {
627
+ this.logger.error(`Failed to close Cloudflare Worker websocket connections within ${String(timeoutMs)}ms.`, error, LIFECYCLE_LOG_CONTEXT);
628
+ });
629
+ }
462
630
  joinRoom(socketId, room) {
463
631
  let rooms = this.socketRooms.get(socketId);
464
632
  if (!rooms) {
@@ -519,6 +687,7 @@ class CloudflareWorkersWebSocketGatewayLifecycleService {
519
687
  return;
520
688
  }
521
689
  this.socketRegistry.delete(socketId);
690
+ this.socketStates.delete(socketId);
522
691
  const rooms = this.socketRooms.get(socketId);
523
692
  if (rooms) {
524
693
  for (const room of rooms) {
@@ -12,20 +12,26 @@ export declare class DenoWebSocketGatewayLifecycleService implements OnApplicati
12
12
  private readonly logger;
13
13
  private readonly adapter;
14
14
  private readonly moduleOptions;
15
+ private isShuttingDown;
16
+ private readonly pendingUpgradeOperations;
15
17
  private pendingUpgradeReservations;
16
18
  private readonly roomSockets;
17
19
  private shutdownPromise;
18
20
  private readonly socketRegistry;
19
21
  private readonly socketRooms;
22
+ private readonly socketStates;
20
23
  constructor(runtimeContainer: Container, compiledModules: readonly CompiledModule[], logger: ApplicationLogger, adapter: HttpApplicationAdapter, moduleOptions: WebSocketModuleOptions);
21
24
  onApplicationBootstrap(): Promise<void>;
22
25
  private assertNoServerBackedGatewayOptIn;
23
26
  onApplicationShutdown(): Promise<void>;
24
27
  onModuleDestroy(): Promise<void>;
25
28
  private createBinding;
29
+ private handleUpgradeRequest;
26
30
  private groupDescriptorsByPath;
27
31
  private bindConnectionHandlers;
28
32
  private createConnectionHandlerState;
33
+ private settleConnectLifecycle;
34
+ private settleDisconnectLifecycle;
29
35
  private attachConnectionListeners;
30
36
  private getBufferedMessageCount;
31
37
  private getQueuedMessageCount;
@@ -50,8 +56,13 @@ export declare class DenoWebSocketGatewayLifecycleService implements OnApplicati
50
56
  private tryReserveUpgradeSlot;
51
57
  private releaseUpgradeReservation;
52
58
  private resolveMaxPayloadBytes;
59
+ private resolveShutdownTimeoutMs;
53
60
  private shutdown;
54
61
  private runShutdownLifecycle;
62
+ private trackPendingUpgradeOperation;
63
+ private awaitPendingUpgradeOperations;
64
+ private closeActiveSockets;
65
+ private awaitHandlerQueueDrain;
55
66
  joinRoom(socketId: string, room: string): void;
56
67
  leaveRoom(socketId: string, room: string): void;
57
68
  broadcastToRoom(room: string, event: string, data: unknown): void;
@@ -1 +1 @@
1
- {"version":3,"file":"deno-service.d.ts","sourceRoot":"","sources":["../../src/deno/deno-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEzI,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAa3D,OAAO,KAAK,EAA8B,oBAAoB,EAA6B,MAAM,aAAa,CAAC;AAC/G,OAAO,KAAK,EAKV,sBAAsB,EACvB,MAAM,iBAAiB,CAAC;AAsGzB;;GAEG;AACH,qBACa,oCACX,YAAW,sBAAsB,EAAE,qBAAqB,EAAE,eAAe,EAAE,oBAAoB;IAS7F,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAXhC,OAAO,CAAC,0BAA0B,CAAK;IACvC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;IAC9D,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA0C;IACzE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;gBAG3C,gBAAgB,EAAE,SAAS,EAC3B,eAAe,EAAE,SAAS,cAAc,EAAE,EAC1C,MAAM,EAAE,iBAAiB,EACzB,OAAO,EAAE,sBAAsB,EAC/B,aAAa,EAAE,sBAAsB;IAGlD,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB7C,OAAO,CAAC,gCAAgC;IAclC,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,OAAO,CAAC,aAAa;IAoDrB,OAAO,CAAC,sBAAsB;YAmBhB,sBAAsB;IAgBpC,OAAO,CAAC,4BAA4B;IAqBpC,OAAO,CAAC,yBAAyB;IAwCjC,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,4BAA4B;IASpC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,0BAA0B;IASlC,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,qBAAqB;IA0C7B,OAAO,CAAC,sBAAsB;YAyDhB,iBAAiB;YAqBjB,gBAAgB;IAQ9B,OAAO,CAAC,yBAAyB;YAsBnB,yBAAyB;YAczB,kBAAkB;YAgBlB,yBAAyB;YAUzB,8BAA8B;IAqB5C,OAAO,CAAC,YAAY;YAUN,uBAAuB;IAmDrC,OAAO,CAAC,qBAAqB;IAmB7B,OAAO,CAAC,yBAAyB;IAUjC,OAAO,CAAC,8BAA8B;IAItC,OAAO,CAAC,qBAAqB;IAS7B,OAAO,CAAC,yBAAyB;IAMjC,OAAO,CAAC,sBAAsB;YAUhB,QAAQ;YAUR,oBAAoB;IAWlC,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAmB9C,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAc/C,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI;IA4BjE,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC;IAU/C,OAAO,CAAC,gBAAgB;CAoBzB"}
1
+ {"version":3,"file":"deno-service.d.ts","sourceRoot":"","sources":["../../src/deno/deno-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEzI,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAa3D,OAAO,KAAK,EAA8B,oBAAoB,EAA6B,MAAM,aAAa,CAAC;AAC/G,OAAO,KAAK,EAKV,sBAAsB,EACvB,MAAM,iBAAiB,CAAC;AAsHzB;;GAEG;AACH,qBACa,oCACX,YAAW,sBAAsB,EAAE,qBAAqB,EAAE,eAAe,EAAE,oBAAoB;IAY7F,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAdhC,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAA4B;IACrE,OAAO,CAAC,0BAA0B,CAAK;IACvC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;IAC9D,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA0C;IACzE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkC;IAC9D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA6C;gBAGvD,gBAAgB,EAAE,SAAS,EAC3B,eAAe,EAAE,SAAS,cAAc,EAAE,EAC1C,MAAM,EAAE,iBAAiB,EACzB,OAAO,EAAE,sBAAsB,EAC/B,aAAa,EAAE,sBAAsB;IAGlD,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB7C,OAAO,CAAC,gCAAgC;IAclC,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,OAAO,CAAC,aAAa;YAUP,oBAAoB;IAyDlC,OAAO,CAAC,sBAAsB;YAmBhB,sBAAsB;IA8BpC,OAAO,CAAC,4BAA4B;IA8BpC,OAAO,CAAC,sBAAsB;IAS9B,OAAO,CAAC,yBAAyB;IASjC,OAAO,CAAC,yBAAyB;IAwCjC,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,4BAA4B;IASpC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,0BAA0B;IASlC,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,qBAAqB;IA0C7B,OAAO,CAAC,sBAAsB;YAyDhB,iBAAiB;YAqBjB,gBAAgB;IAQ9B,OAAO,CAAC,yBAAyB;YAyBnB,yBAAyB;YAczB,kBAAkB;YAgBlB,yBAAyB;YAUzB,8BAA8B;IAqB5C,OAAO,CAAC,YAAY;YAUN,uBAAuB;IA0DrC,OAAO,CAAC,qBAAqB;IAmB7B,OAAO,CAAC,yBAAyB;IAUjC,OAAO,CAAC,8BAA8B;IAItC,OAAO,CAAC,qBAAqB;IAS7B,OAAO,CAAC,yBAAyB;IAMjC,OAAO,CAAC,sBAAsB;IAU9B,OAAO,CAAC,wBAAwB;YAUlB,QAAQ;YAUR,oBAAoB;IAmBlC,OAAO,CAAC,4BAA4B;YAyBtB,6BAA6B;YA4C7B,kBAAkB;YAoBlB,sBAAsB;IAkDpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAmB9C,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAc/C,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI;IA4BjE,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC;IAU/C,OAAO,CAAC,gBAAgB;CAqBzB"}
@@ -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.4",
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
72
  "@fluojs/core": "^1.0.0-beta.2",
74
- "@fluojs/http": "^1.0.0-beta.3",
75
- "@fluojs/runtime": "^1.0.0-beta.4"
73
+ "@fluojs/di": "^1.0.0-beta.5",
74
+ "@fluojs/http": "^1.0.0-beta.4",
75
+ "@fluojs/runtime": "^1.0.0-beta.5"
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.4",
81
+ "@fluojs/platform-express": "^1.0.0-beta.4",
82
+ "@fluojs/platform-fastify": "^1.0.0-beta.5"
83
83
  },
84
84
  "scripts": {
85
85
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",