@fluojs/websockets 1.0.0-beta.3 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +13 -11
- package/README.md +13 -11
- 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/decorators.d.ts.map +1 -1
- package/dist/decorators.js +2 -2
- 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 +8 -8
|
@@ -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.5",
|
|
13
13
|
"private": false,
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"repository": {
|
|
@@ -69,17 +69,17 @@
|
|
|
69
69
|
],
|
|
70
70
|
"dependencies": {
|
|
71
71
|
"ws": "^8.18.3",
|
|
72
|
-
"@fluojs/
|
|
73
|
-
"@fluojs/
|
|
74
|
-
"@fluojs/http": "^1.0.0-beta.
|
|
75
|
-
"@fluojs/runtime": "^1.0.0-beta.
|
|
72
|
+
"@fluojs/core": "^1.0.0-beta.4",
|
|
73
|
+
"@fluojs/di": "^1.0.0-beta.6",
|
|
74
|
+
"@fluojs/http": "^1.0.0-beta.10",
|
|
75
|
+
"@fluojs/runtime": "^1.0.0-beta.11"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"@types/ws": "^8.18.1",
|
|
79
79
|
"vitest": "^3.2.4",
|
|
80
|
-
"@fluojs/platform-bun": "^1.0.0-beta.
|
|
81
|
-
"@fluojs/platform-express": "^1.0.0-beta.
|
|
82
|
-
"@fluojs/platform-fastify": "^1.0.0-beta.
|
|
80
|
+
"@fluojs/platform-bun": "^1.0.0-beta.6",
|
|
81
|
+
"@fluojs/platform-express": "^1.0.0-beta.6",
|
|
82
|
+
"@fluojs/platform-fastify": "^1.0.0-beta.8"
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
85
|
"prebuild": "node ../../tooling/scripts/clean-dist.mjs",
|