@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 +3 -1
- package/README.md +3 -1
- package/dist/bun/bun-service.d.ts +11 -0
- package/dist/bun/bun-service.d.ts.map +1 -1
- package/dist/bun/bun-service.js +185 -5
- package/dist/cloudflare-workers/cloudflare-workers-service.d.ts +11 -0
- package/dist/cloudflare-workers/cloudflare-workers-service.d.ts.map +1 -1
- package/dist/cloudflare-workers/cloudflare-workers-service.js +218 -49
- package/dist/deno/deno-service.d.ts +11 -0
- package/dist/deno/deno-service.d.ts.map +1 -1
- package/dist/deno/deno-service.js +217 -48
- package/package.json +7 -7
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;
|
|
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;AA4H7D;;GAEG;AACH,qBACa,mCACX,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,CAAwD;IACvF,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;IAqB7C,OAAO,CAAC,gCAAgC;IAclC,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC,OAAO,CAAC,aAAa;IAmCrB,OAAO,CAAC,sBAAsB;YAmBhB,oBAAoB;YA0DpB,sBAAsB;IA0BpC,OAAO,CAAC,4BAA4B;IAkCpC,OAAO,CAAC,sBAAsB;IAS9B,OAAO,CAAC,sBAAsB;IAS9B,OAAO,CAAC,yBAAyB;IASjC,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;YAyBnB,yBAAyB;YAczB,kBAAkB;YASlB,yBAAyB;IASvC,OAAO,CAAC,8BAA8B;IAoBtC,OAAO,CAAC,wBAAwB;YAUlB,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,yBAAyB;IAcjC,OAAO,CAAC,wBAAwB;YAUlB,QAAQ;YAUR,oBAAoB;IAoBlC,OAAO,CAAC,4BAA4B;YAetB,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;CAiBzB"}
|
package/dist/bun/bun-service.js
CHANGED
|
@@ -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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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;
|
|
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:
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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;
|
|
1
|
+
{"version":3,"file":"deno-service.d.ts","sourceRoot":"","sources":["../../src/deno/deno-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 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:
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
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/
|
|
75
|
-
"@fluojs/
|
|
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.
|
|
81
|
-
"@fluojs/platform-express": "^1.0.0-beta.
|
|
82
|
-
"@fluojs/platform-fastify": "^1.0.0-beta.
|
|
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",
|