redweb 0.7.7 → 0.9.0
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/CHANGELOG.md +11 -0
- package/README.md +458 -276
- package/client.d.ts +42 -0
- package/client.js +55 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
- package/docs/PRODUCTION_READINESS.md +68 -0
- package/docs/VERIFICATION_EVIDENCE.md +20 -0
- package/index.d.ts +308 -60
- package/index.js +24 -6
- package/package.json +25 -6
- package/src/htmx/HtmxRenderer.js +10 -2
- package/src/http/BaseHttpServer.js +84 -29
- package/src/http/HttpServer.js +18 -10
- package/src/http/HttpsServer.js +19 -11
- package/src/serverLifecycle.js +46 -0
- package/src/ws/AdmissionPolicy.js +145 -0
- package/src/ws/BaseHandler.js +40 -32
- package/src/ws/BaseSocketServer.js +182 -19
- package/src/ws/DefaultHandler.js +2 -3
- package/src/ws/DefaultRoute.js +4 -3
- package/src/ws/DistributionBridge.js +271 -0
- package/src/ws/FixedStepService.js +74 -0
- package/src/ws/HeartbeatMonitor.js +75 -0
- package/src/ws/Metrics.js +34 -0
- package/src/ws/ProtocolPolicy.js +130 -0
- package/src/ws/RoomRegistry.js +117 -0
- package/src/ws/RouteRuntime.js +146 -0
- package/src/ws/SecureSocketServer.js +9 -12
- package/src/ws/SessionRegistry.js +135 -0
- package/src/ws/SocketRoute.js +503 -116
- package/src/ws/SocketServer.js +8 -11
- package/src/ws/TaskQueue.js +64 -0
- package/src/ws/TokenBucket.js +31 -0
- package/src/ws/TransportPolicy.js +68 -0
- package/src/ws/index.js +7 -2
- package/src/ws/protocol-schema.json +13 -0
- package/src/ws/protocol-validation.js +21 -0
- package/src/ws/shutdown.js +33 -0
- package/src/ws/util.js +34 -5
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef {Object} SocketServerOptions
|
|
3
|
-
* @property {import('http').Server} [server] HTTP server to bind to
|
|
4
|
-
* @property {number} [port=3000] Port to listen on
|
|
5
|
-
* @property {boolean} [listen=true] Whether owned servers should automatically start listening
|
|
6
|
-
* @property {Array<new () => import('./SocketRoute').SocketRoute>} [routes]
|
|
7
|
-
*/
|
|
3
|
+
* @property {import('http').Server} [server] HTTP server to bind to
|
|
4
|
+
* @property {number} [port=3000] Port to listen on
|
|
5
|
+
* @property {boolean} [listen=true] Whether owned servers should automatically start listening
|
|
6
|
+
* @property {Array<new () => import('./SocketRoute').SocketRoute>} [routes]
|
|
7
|
+
*/
|
|
8
8
|
|
|
9
9
|
const DefaultRoute = require('./DefaultRoute');
|
|
10
|
+
const { PLACEMENT_REDIRECT, ADMISSION_SETTLEMENT } = require('./AdmissionPolicy');
|
|
11
|
+
const { PROTOCOL_REJECTION } = require('./ProtocolPolicy');
|
|
12
|
+
const {
|
|
13
|
+
listenServer,
|
|
14
|
+
closeServer,
|
|
15
|
+
settleTasks,
|
|
16
|
+
throwCleanupErrors,
|
|
17
|
+
validateListenerOptions,
|
|
18
|
+
} = require('../serverLifecycle');
|
|
10
19
|
|
|
11
20
|
const SOCKET_OPTIONS = {
|
|
12
|
-
port: 3000,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
port: 3000,
|
|
22
|
+
bind: '0.0.0.0',
|
|
23
|
+
ssl: null,
|
|
24
|
+
listen: true,
|
|
25
|
+
routes: [],
|
|
26
|
+
fallbackToRoot: false,
|
|
27
|
+
closeServerOnShutdown: undefined,
|
|
28
|
+
logger: console,
|
|
29
|
+
listenCallback: undefined,
|
|
30
|
+
};
|
|
17
31
|
|
|
18
32
|
/**
|
|
19
33
|
* Base WebSocket server
|
|
@@ -23,16 +37,61 @@ class BaseSocketServer {
|
|
|
23
37
|
* @param {import('http').Server} server
|
|
24
38
|
* @param {SocketServerOptions} [options]
|
|
25
39
|
*/
|
|
26
|
-
constructor(server, options = {}) {
|
|
27
|
-
|
|
40
|
+
constructor(server, options = {}, ownsServer = false, name = 'SocketServer') {
|
|
41
|
+
if (!server || typeof server.on !== 'function') throw new TypeError('A Node HTTP(S) server is required.');
|
|
28
42
|
Object.assign(this, { ...SOCKET_OPTIONS, ...options });
|
|
43
|
+
validateListenerOptions(this);
|
|
44
|
+
if (!Array.isArray(this.routes)) throw new TypeError('`routes` must be an array.');
|
|
29
45
|
this.server = server;
|
|
46
|
+
this.ownsServer = ownsServer;
|
|
47
|
+
this.closeServerOnShutdown = options.closeServerOnShutdown ?? ownsServer;
|
|
48
|
+
this.draining = false;
|
|
49
|
+
this.pendingUpgrades = new Map();
|
|
50
|
+
this.rawConnections = this.closeServerOnShutdown ? new Set() : null;
|
|
51
|
+
this._connectionHandler = this.rawConnections ? socket => {
|
|
52
|
+
this.rawConnections.add(socket);
|
|
53
|
+
socket.once('close', () => this.rawConnections.delete(socket));
|
|
54
|
+
} : null;
|
|
55
|
+
if (this._connectionHandler) this.server.on('connection', this._connectionHandler);
|
|
30
56
|
|
|
31
57
|
/* ─── ROUTE INITIALISATION ─────────────────────────── */
|
|
32
|
-
|
|
33
|
-
this.routes =
|
|
58
|
+
const RouteClasses = options.routes?.length ? [...options.routes] : [DefaultRoute];
|
|
59
|
+
this.routes = [];
|
|
60
|
+
try {
|
|
61
|
+
for (const RouteClass of RouteClasses) {
|
|
62
|
+
const route = new RouteClass(server, { logger: this.logger });
|
|
63
|
+
if (this.routes.some(existing => existing.path === route.path)) {
|
|
64
|
+
this.disposeRoutes([route]);
|
|
65
|
+
throw new Error('WebSocket route paths must be unique.');
|
|
66
|
+
}
|
|
67
|
+
this.routes.push(route);
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
this.disposeRoutes(this.routes);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this._upgradeHandler = this.handleUpgrade.bind(this);
|
|
75
|
+
this.server.on('upgrade', this._upgradeHandler);
|
|
34
76
|
|
|
35
|
-
this.
|
|
77
|
+
const shouldListen = (ownsServer && this.listen !== false) || (!ownsServer && options.listen === true);
|
|
78
|
+
if (shouldListen) {
|
|
79
|
+
listenServer(this.server, {
|
|
80
|
+
port: this.port,
|
|
81
|
+
bind: this.bind,
|
|
82
|
+
callback: this.listenCallback,
|
|
83
|
+
logger: this.logger,
|
|
84
|
+
name,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
disposeRoutes(routes) {
|
|
90
|
+
routes.forEach(route => {
|
|
91
|
+
Promise.resolve()
|
|
92
|
+
.then(() => route.shutdown?.())
|
|
93
|
+
.catch(error => this.logger?.error?.('Error shutting down route:', error));
|
|
94
|
+
});
|
|
36
95
|
}
|
|
37
96
|
|
|
38
97
|
handleUpgrade(req, sock, head) {
|
|
@@ -49,29 +108,133 @@ class BaseSocketServer {
|
|
|
49
108
|
|
|
50
109
|
const route =
|
|
51
110
|
this.routes.find(r => r.path === path) ||
|
|
52
|
-
this.routes.find(r => r.path === '/');
|
|
111
|
+
(this.fallbackToRoot ? this.routes.find(r => r.path === '/') : undefined);
|
|
53
112
|
|
|
54
113
|
if (!route) return sock.destroy();
|
|
114
|
+
if (this.draining) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
|
|
115
|
+
if (route.isReady?.() === false) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
|
|
55
116
|
|
|
117
|
+
if (route.admissionPolicy || route.protocolPolicy || route.transportPolicy && route.transportPolicy.maxConnections !== Infinity) {
|
|
118
|
+
let reservation;
|
|
119
|
+
try {
|
|
120
|
+
reservation = route.reserveUpgrade(req);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.logger?.error?.('WebSocket admission reservation failed:', error);
|
|
123
|
+
}
|
|
124
|
+
if (!reservation) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
this.pendingUpgrades.set(sock, { controller, route, reservation });
|
|
127
|
+
void Promise.resolve()
|
|
128
|
+
.then(() => route.authorizeUpgrade(req, sock, controller.signal))
|
|
129
|
+
.then(accepted => {
|
|
130
|
+
if (sock.destroyed) return;
|
|
131
|
+
if (this.draining) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
|
|
132
|
+
if (!accepted) {
|
|
133
|
+
const redirect = req[PLACEMENT_REDIRECT];
|
|
134
|
+
if (redirect) return this.rejectUpgrade(sock, 307, 'Temporary Redirect', { Location: redirect });
|
|
135
|
+
const rejection = req[PROTOCOL_REJECTION];
|
|
136
|
+
return rejection
|
|
137
|
+
? this.rejectUpgrade(sock, rejection.statusCode, rejection.statusText, rejection.headers)
|
|
138
|
+
: this.rejectUpgrade(sock, 401, 'Unauthorized');
|
|
139
|
+
}
|
|
140
|
+
this.completeUpgrade(route, req, sock, head);
|
|
141
|
+
})
|
|
142
|
+
.catch(error => {
|
|
143
|
+
this.logger?.error?.('WebSocket admission failed:', error);
|
|
144
|
+
if (!sock.destroyed) this.rejectUpgrade(sock, 401, 'Unauthorized');
|
|
145
|
+
})
|
|
146
|
+
.finally(async () => {
|
|
147
|
+
await req[ADMISSION_SETTLEMENT];
|
|
148
|
+
this.pendingUpgrades.delete(sock);
|
|
149
|
+
route.releaseUpgrade(reservation);
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.completeUpgrade(route, req, sock, head);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
completeUpgrade(route, req, sock, head) {
|
|
56
158
|
route.server.handleUpgrade(req, sock, head, (s, r) =>
|
|
57
159
|
route.server.emit('connection', s, r)
|
|
58
160
|
);
|
|
59
161
|
}
|
|
60
162
|
|
|
163
|
+
rejectUpgrade(socket, statusCode, statusText, headers = {}) {
|
|
164
|
+
try {
|
|
165
|
+
const extraHeaders = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join('');
|
|
166
|
+
socket.end?.(
|
|
167
|
+
`HTTP/1.1 ${statusCode} ${statusText}\r\n` +
|
|
168
|
+
extraHeaders +
|
|
169
|
+
'Connection: close\r\n' +
|
|
170
|
+
'Content-Length: 0\r\n\r\n'
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
socket.destroy?.();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
61
177
|
/**
|
|
62
178
|
* Dynamically attach a new route at runtime
|
|
63
179
|
* @param {new () => import('./SocketRoute').SocketRoute} RouteClass
|
|
64
180
|
*/
|
|
65
181
|
addRoute(RouteClass) {
|
|
66
|
-
|
|
182
|
+
const route = new RouteClass(this.server, { logger: this.logger });
|
|
183
|
+
if (this.routes.some(existing => existing.path === route.path)) {
|
|
184
|
+
this.disposeRoutes([route]);
|
|
185
|
+
throw new Error(`A WebSocket route already exists at ${route.path}.`);
|
|
186
|
+
}
|
|
187
|
+
this.routes.push(route);
|
|
188
|
+
return route;
|
|
67
189
|
}
|
|
68
190
|
|
|
69
191
|
/**
|
|
70
192
|
* Gracefully tear down all routes (and their services)
|
|
71
193
|
*/
|
|
72
194
|
shutdown() {
|
|
73
|
-
this.
|
|
74
|
-
this.
|
|
195
|
+
if (!this._shutdownPromise) this._shutdownPromise = this.performShutdown();
|
|
196
|
+
return this._shutdownPromise;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
isReady() {
|
|
200
|
+
return !this.draining && this.routes.every(route => route.isReady?.() !== false);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
beginDrain() {
|
|
204
|
+
if (this.draining) return false;
|
|
205
|
+
this.draining = true;
|
|
206
|
+
this.pendingUpgrades.forEach(({ controller }) => controller.abort());
|
|
207
|
+
this.routes.forEach(route => route.beginDrain?.());
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async performShutdown() {
|
|
212
|
+
this.beginDrain();
|
|
213
|
+
this.server.off?.('upgrade', this._upgradeHandler);
|
|
214
|
+
const deadline = Date.now() + Math.max(0, ...this.routes.map(route => route.shutdownTimeoutMs));
|
|
215
|
+
const errors = await settleTasks(this.routes.map(route => () => route.shutdown?.()));
|
|
216
|
+
if (this.closeServerOnShutdown && this.server.listening) {
|
|
217
|
+
try {
|
|
218
|
+
await this.closeOwnedServer(Math.max(0, deadline - Date.now()));
|
|
219
|
+
} catch (error) {
|
|
220
|
+
errors.push(error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (this._connectionHandler) this.server.off?.('connection', this._connectionHandler);
|
|
224
|
+
throwCleanupErrors(errors, 'One or more WebSocket server cleanup operations failed.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
closeOwnedServer(timeoutMs) {
|
|
228
|
+
let timer;
|
|
229
|
+
const closing = closeServer(this.server);
|
|
230
|
+
const timeout = new Promise(resolve => {
|
|
231
|
+
timer = setTimeout(() => {
|
|
232
|
+
this.rawConnections?.forEach(socket => socket.destroy?.());
|
|
233
|
+
resolve();
|
|
234
|
+
}, timeoutMs);
|
|
235
|
+
timer.unref?.();
|
|
236
|
+
});
|
|
237
|
+
return Promise.race([closing, timeout]).finally(() => clearTimeout(timer));
|
|
75
238
|
}
|
|
76
239
|
}
|
|
77
240
|
|
package/src/ws/DefaultHandler.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const { BaseHandler } = require("./BaseHandler");
|
|
2
|
-
const { sendJson } = require("./util");
|
|
3
2
|
|
|
4
3
|
class DefaultHandler extends BaseHandler {
|
|
5
4
|
constructor() {
|
|
@@ -7,8 +6,8 @@ class DefaultHandler extends BaseHandler {
|
|
|
7
6
|
}
|
|
8
7
|
|
|
9
8
|
onMessage(socket, message) {
|
|
10
|
-
socket.
|
|
9
|
+
socket.sendJson({ message: `I got your message of ${JSON.stringify(message)}` });
|
|
11
10
|
}
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
module.exports = DefaultHandler;
|
|
13
|
+
module.exports = DefaultHandler;
|
package/src/ws/DefaultRoute.js
CHANGED
|
@@ -2,13 +2,14 @@ const SocketRoute = require("./SocketRoute");
|
|
|
2
2
|
const DefaultHandler = require('./DefaultHandler');
|
|
3
3
|
|
|
4
4
|
class DefaultRoute extends SocketRoute {
|
|
5
|
-
constructor(server) {
|
|
5
|
+
constructor(server, options = {}) {
|
|
6
6
|
super({
|
|
7
7
|
server,
|
|
8
8
|
path: "/",
|
|
9
|
-
handlers: [DefaultHandler]
|
|
9
|
+
handlers: [DefaultHandler],
|
|
10
|
+
logger: options.logger,
|
|
10
11
|
})
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
module.exports = DefaultRoute;
|
|
15
|
+
module.exports = DefaultRoute;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
const { randomUUID } = require('crypto');
|
|
2
|
+
const { performance } = require('perf_hooks');
|
|
3
|
+
const { throwCleanupErrors } = require('../serverLifecycle');
|
|
4
|
+
|
|
5
|
+
class DistributionBridge {
|
|
6
|
+
constructor(options, onEvent, logger = console, clock = () => performance.now()) {
|
|
7
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
8
|
+
throw new TypeError('`distribution` must be an object.');
|
|
9
|
+
}
|
|
10
|
+
const {
|
|
11
|
+
adapter,
|
|
12
|
+
channel,
|
|
13
|
+
nodeId = randomUUID(),
|
|
14
|
+
maxEventBytes = 64 * 1024,
|
|
15
|
+
maxSeenEvents = 10_000,
|
|
16
|
+
seenTtlMs = 60_000,
|
|
17
|
+
lifecycleTimeoutMs = 5000,
|
|
18
|
+
publishTimeoutMs = lifecycleTimeoutMs,
|
|
19
|
+
maxConcurrentPublishes = 64,
|
|
20
|
+
maxConcurrentEvents = 64,
|
|
21
|
+
required = false,
|
|
22
|
+
} = options;
|
|
23
|
+
if (!adapter || typeof adapter !== 'object') throw new TypeError('`distribution.adapter` is required.');
|
|
24
|
+
['publish', 'subscribe'].forEach(method => {
|
|
25
|
+
if (typeof adapter[method] !== 'function') throw new TypeError(`\`distribution.adapter.${method}\` must be a function.`);
|
|
26
|
+
});
|
|
27
|
+
['start', 'unsubscribe', 'close'].forEach(method => {
|
|
28
|
+
if (adapter[method] !== undefined && typeof adapter[method] !== 'function') {
|
|
29
|
+
throw new TypeError(`\`distribution.adapter.${method}\` must be a function.`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
if (typeof channel !== 'string' || !channel) throw new TypeError('`distribution.channel` must be a non-empty string.');
|
|
33
|
+
if (typeof nodeId !== 'string' || !nodeId || nodeId.length > 256) {
|
|
34
|
+
throw new TypeError('`distribution.nodeId` must be a non-empty string of at most 256 characters.');
|
|
35
|
+
}
|
|
36
|
+
const integers = {
|
|
37
|
+
maxEventBytes,
|
|
38
|
+
maxSeenEvents,
|
|
39
|
+
seenTtlMs,
|
|
40
|
+
lifecycleTimeoutMs,
|
|
41
|
+
publishTimeoutMs,
|
|
42
|
+
maxConcurrentPublishes,
|
|
43
|
+
maxConcurrentEvents,
|
|
44
|
+
};
|
|
45
|
+
for (const [name, value] of Object.entries(integers)) {
|
|
46
|
+
if (!Number.isInteger(value) || value < 1) throw new TypeError(`\`distribution.${name}\` must be a positive integer.`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof required !== 'boolean') throw new TypeError('`distribution.required` must be a boolean.');
|
|
49
|
+
if (typeof onEvent !== 'function') throw new TypeError('`distribution.onEvent` must be a function.');
|
|
50
|
+
if (typeof clock !== 'function') throw new TypeError('`clock` must be a function.');
|
|
51
|
+
Object.assign(this, {
|
|
52
|
+
adapter,
|
|
53
|
+
channel,
|
|
54
|
+
nodeId,
|
|
55
|
+
maxEventBytes,
|
|
56
|
+
maxSeenEvents,
|
|
57
|
+
seenTtlMs,
|
|
58
|
+
lifecycleTimeoutMs,
|
|
59
|
+
publishTimeoutMs,
|
|
60
|
+
maxConcurrentPublishes,
|
|
61
|
+
maxConcurrentEvents,
|
|
62
|
+
required,
|
|
63
|
+
onEvent,
|
|
64
|
+
logger,
|
|
65
|
+
clock,
|
|
66
|
+
});
|
|
67
|
+
this.seen = new Map();
|
|
68
|
+
this.publishes = new Set();
|
|
69
|
+
this.events = new Set();
|
|
70
|
+
this.closed = false;
|
|
71
|
+
this.healthy = false;
|
|
72
|
+
this.subscribed = false;
|
|
73
|
+
this.ready = this.start();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async start() {
|
|
77
|
+
try {
|
|
78
|
+
await this.withTimeout(
|
|
79
|
+
signal => this.adapter.start?.(signal),
|
|
80
|
+
this.lifecycleTimeoutMs,
|
|
81
|
+
'start',
|
|
82
|
+
() => this.adapter.close?.()
|
|
83
|
+
);
|
|
84
|
+
if (this.closed) return false;
|
|
85
|
+
const unsubscribe = await this.withTimeout(
|
|
86
|
+
signal => this.adapter.subscribe(this.channel, event => this.receive(event), signal),
|
|
87
|
+
this.lifecycleTimeoutMs,
|
|
88
|
+
'subscribe',
|
|
89
|
+
lateUnsubscribe => typeof lateUnsubscribe === 'function'
|
|
90
|
+
? lateUnsubscribe()
|
|
91
|
+
: this.adapter.unsubscribe?.(this.channel)
|
|
92
|
+
);
|
|
93
|
+
if (typeof unsubscribe === 'function') this.unsubscribe = unsubscribe;
|
|
94
|
+
this.subscribed = true;
|
|
95
|
+
this.healthy = true;
|
|
96
|
+
return true;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.logger?.error?.('Distribution adapter failed to start:', error);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
withTimeout(operation, timeoutMs, name, compensateLate) {
|
|
104
|
+
let timer;
|
|
105
|
+
let timedOut = false;
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const task = Promise.resolve().then(() => operation(controller.signal));
|
|
108
|
+
void task.then(value => {
|
|
109
|
+
if (!timedOut || !compensateLate) return;
|
|
110
|
+
Promise.resolve()
|
|
111
|
+
.then(() => compensateLate(value))
|
|
112
|
+
.catch(error => this.logger?.error?.(`Late distribution ${name} cleanup failed:`, error));
|
|
113
|
+
}, error => {
|
|
114
|
+
if (timedOut) this.logger?.error?.(`Distribution adapter ${name} failed after timeout:`, error);
|
|
115
|
+
});
|
|
116
|
+
const timeout = new Promise((_, reject) => {
|
|
117
|
+
timer = setTimeout(() => {
|
|
118
|
+
timedOut = true;
|
|
119
|
+
controller.abort();
|
|
120
|
+
reject(new Error(`Distribution adapter ${name} timed out.`));
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
timer.unref();
|
|
123
|
+
});
|
|
124
|
+
return Promise.race([task, timeout]).finally(() => clearTimeout(timer));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
isReady() {
|
|
128
|
+
return this.healthy && !this.closed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
publish(type, payload) {
|
|
132
|
+
if (this.closed || typeof type !== 'string' || !type || this.publishes.size >= this.maxConcurrentPublishes) {
|
|
133
|
+
return Promise.resolve(false);
|
|
134
|
+
}
|
|
135
|
+
const event = { id: randomUUID(), source: this.nodeId, type, payload };
|
|
136
|
+
const serialized = this.serialize(event);
|
|
137
|
+
if (!serialized) return Promise.resolve(false);
|
|
138
|
+
const task = this.performPublish(serialized);
|
|
139
|
+
this.track(this.publishes, task);
|
|
140
|
+
return task;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async performPublish(serialized) {
|
|
144
|
+
if (!await this.ready || this.closed) return false;
|
|
145
|
+
try {
|
|
146
|
+
await this.withTimeout(
|
|
147
|
+
signal => this.adapter.publish(this.channel, serialized, signal),
|
|
148
|
+
this.publishTimeoutMs,
|
|
149
|
+
'publish'
|
|
150
|
+
);
|
|
151
|
+
this.healthy = true;
|
|
152
|
+
return true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.healthy = false;
|
|
155
|
+
this.logger?.error?.('Distribution publish failed:', error);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
track(collection, task) {
|
|
161
|
+
collection.add(task);
|
|
162
|
+
const cleanup = () => collection.delete(task);
|
|
163
|
+
void task.then(cleanup, cleanup);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
receive(input) {
|
|
167
|
+
if (this.closed || this.events.size >= this.maxConcurrentEvents) return false;
|
|
168
|
+
let event;
|
|
169
|
+
try {
|
|
170
|
+
const serialized = typeof input === 'string' ? input : JSON.stringify(input);
|
|
171
|
+
if (Buffer.byteLength(serialized) > this.maxEventBytes) return false;
|
|
172
|
+
event = typeof input === 'string' ? JSON.parse(input) : input;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (!this.isValid(event) || event.source === this.nodeId || this.hasSeen(event.id)) return false;
|
|
177
|
+
this.remember(event.id);
|
|
178
|
+
const task = Promise.resolve()
|
|
179
|
+
.then(() => this.onEvent(event))
|
|
180
|
+
.catch(error => this.logger?.error?.('Distribution event handler failed:', error));
|
|
181
|
+
this.track(this.events, task);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
isValid(event) {
|
|
186
|
+
return Boolean(
|
|
187
|
+
event &&
|
|
188
|
+
typeof event === 'object' &&
|
|
189
|
+
typeof event.id === 'string' && event.id && event.id.length <= 256 &&
|
|
190
|
+
typeof event.source === 'string' && event.source && event.source.length <= 256 &&
|
|
191
|
+
typeof event.type === 'string' && event.type && event.type.length <= 256
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
serialize(event) {
|
|
196
|
+
try {
|
|
197
|
+
const serialized = JSON.stringify(event);
|
|
198
|
+
return Buffer.byteLength(serialized) <= this.maxEventBytes ? serialized : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
hasSeen(id) {
|
|
205
|
+
const expiry = this.seen.get(id);
|
|
206
|
+
if (expiry === undefined) return false;
|
|
207
|
+
if (expiry <= this.clock()) {
|
|
208
|
+
this.seen.delete(id);
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
evictExpired(now) {
|
|
215
|
+
while (this.seen.size) {
|
|
216
|
+
const [eventId, expiry] = this.seen.entries().next().value;
|
|
217
|
+
if (expiry > now) return;
|
|
218
|
+
this.seen.delete(eventId);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
remember(id) {
|
|
223
|
+
const now = this.clock();
|
|
224
|
+
this.evictExpired(now);
|
|
225
|
+
while (this.seen.size >= this.maxSeenEvents) this.seen.delete(this.seen.keys().next().value);
|
|
226
|
+
this.seen.set(id, now + this.seenTtlMs);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async stopSubscription() {
|
|
230
|
+
if (!this.subscribed) return;
|
|
231
|
+
this.subscribed = false;
|
|
232
|
+
await this.withTimeout(
|
|
233
|
+
signal => this.unsubscribe ? this.unsubscribe() : this.adapter.unsubscribe?.(this.channel, signal),
|
|
234
|
+
this.lifecycleTimeoutMs,
|
|
235
|
+
'unsubscribe'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async drainActivity() {
|
|
240
|
+
await this.withTimeout(
|
|
241
|
+
() => Promise.allSettled([...this.publishes, ...this.events]),
|
|
242
|
+
this.lifecycleTimeoutMs,
|
|
243
|
+
'drain'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async close() {
|
|
248
|
+
if (this.closed) return;
|
|
249
|
+
this.closed = true;
|
|
250
|
+
this.healthy = false;
|
|
251
|
+
await this.ready;
|
|
252
|
+
const errors = [];
|
|
253
|
+
for (const operation of [
|
|
254
|
+
() => this.stopSubscription(),
|
|
255
|
+
() => this.drainActivity(),
|
|
256
|
+
() => this.withTimeout(signal => this.adapter.close?.(signal), this.lifecycleTimeoutMs, 'close'),
|
|
257
|
+
]) {
|
|
258
|
+
try {
|
|
259
|
+
await operation();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
errors.push(error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
this.seen.clear();
|
|
265
|
+
this.publishes.clear();
|
|
266
|
+
this.events.clear();
|
|
267
|
+
throwCleanupErrors(errors, 'One or more distribution adapter cleanup operations failed.');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = DistributionBridge;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const { performance } = require('perf_hooks');
|
|
2
|
+
const SocketService = require('./SocketService');
|
|
3
|
+
|
|
4
|
+
class FixedStepService extends SocketService {
|
|
5
|
+
constructor(name, tickRateMs, maxCatchUpTicks = 5, maxRetainedLagMs = tickRateMs * maxCatchUpTicks) {
|
|
6
|
+
super(name, tickRateMs);
|
|
7
|
+
if (!Number.isInteger(tickRateMs) || tickRateMs < 1) {
|
|
8
|
+
throw new TypeError('`tickRateMs` must be a positive integer.');
|
|
9
|
+
}
|
|
10
|
+
if (!Number.isInteger(maxCatchUpTicks) || maxCatchUpTicks < 1) {
|
|
11
|
+
throw new TypeError('`maxCatchUpTicks` must be a positive integer.');
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isInteger(maxRetainedLagMs) || maxRetainedLagMs < tickRateMs) {
|
|
14
|
+
throw new TypeError('`maxRetainedLagMs` must be an integer greater than or equal to `tickRateMs`.');
|
|
15
|
+
}
|
|
16
|
+
this.maxCatchUpTicks = maxCatchUpTicks;
|
|
17
|
+
this.maxRetainedLagMs = maxRetainedLagMs;
|
|
18
|
+
this.tick = 0;
|
|
19
|
+
this.accumulatorMs = 0;
|
|
20
|
+
this._runningPromise = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
now() {
|
|
24
|
+
return performance.now();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onInit(route) {
|
|
28
|
+
this.route = route;
|
|
29
|
+
this.lastTime = this.now();
|
|
30
|
+
this._tickHandle = setInterval(() => this.pulse(), this.tickRateMs);
|
|
31
|
+
this._tickHandle.unref?.();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pulse() {
|
|
35
|
+
if (this._runningPromise) return this._runningPromise;
|
|
36
|
+
const now = this.now();
|
|
37
|
+
const accumulated = this.accumulatorMs + Math.max(0, now - this.lastTime);
|
|
38
|
+
const droppedLagMs = Math.max(0, accumulated - this.maxRetainedLagMs);
|
|
39
|
+
this.accumulatorMs = Math.min(accumulated, this.maxRetainedLagMs);
|
|
40
|
+
this.lastTime = now;
|
|
41
|
+
if (droppedLagMs) {
|
|
42
|
+
this.route?.metrics?.observe('redweb.fixed_step.lag_dropped', droppedLagMs);
|
|
43
|
+
try {
|
|
44
|
+
this.onLagDropped?.(droppedLagMs);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.route?.logger?.error?.('Fixed-step lag hook failed:', error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const due = Math.min(this.maxCatchUpTicks, Math.floor(this.accumulatorMs / this.tickRateMs));
|
|
50
|
+
if (!due) return Promise.resolve();
|
|
51
|
+
this.accumulatorMs -= due * this.tickRateMs;
|
|
52
|
+
this._runningPromise = this.runTicks(due).finally(() => { this._runningPromise = null; });
|
|
53
|
+
return this._runningPromise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async runTicks(count) {
|
|
57
|
+
for (let index = 0; index < count; index += 1) {
|
|
58
|
+
this.tick += 1;
|
|
59
|
+
try {
|
|
60
|
+
await this.onTick?.(this.tickRateMs, this.tick);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
this.route?.logger?.error?.('Fixed-step service tick failed:', error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async onShutdown() {
|
|
68
|
+
if (this._tickHandle) clearInterval(this._tickHandle);
|
|
69
|
+
this._tickHandle = null;
|
|
70
|
+
await this._runningPromise;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = FixedStepService;
|