redweb 0.8.0 → 0.10.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 +28 -0
- package/README.md +573 -307
- package/client.d.ts +42 -0
- package/client.js +55 -0
- package/docs/LIVE_HTML.md +313 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
- package/docs/PRODUCTION_READINESS.md +68 -0
- package/docs/VERIFICATION_EVIDENCE.md +20 -0
- package/examples/live-html/cards.css +36 -0
- package/examples/live-html/cards.html +11 -0
- package/examples/live-html/cards.js +91 -0
- package/examples/live-html/cards.ts +35 -0
- package/examples/live-html/chatroom.css +156 -0
- package/examples/live-html/chatroom.js +268 -0
- package/examples/live-html/chatroom.ts +217 -0
- package/examples/live-html/components.css +7 -0
- package/examples/live-html/components.js +113 -0
- package/examples/live-html/components.ts +41 -0
- package/examples/live-html/counter.css +24 -0
- package/examples/live-html/counter.html +10 -0
- package/examples/live-html/counter.js +73 -0
- package/examples/live-html/counter.ts +21 -0
- package/examples/live-html/tsconfig.json +16 -0
- package/index.d.ts +538 -114
- package/index.js +44 -12
- package/package.json +39 -15
- package/src/htmx/Html.js +133 -0
- package/src/htmx/HtmlRenderer.js +88 -0
- package/src/htmx/HtmlSyntax.js +168 -0
- package/src/htmx/LiveHtmlServer.js +91 -0
- package/src/htmx/LivePage.js +232 -0
- package/src/htmx/PageAssetLoader.js +34 -0
- package/src/htmx/PageManager.js +435 -0
- package/src/htmx/StaticExporter.js +78 -0
- package/src/htmx/StaticSite.js +182 -0
- package/src/htmx/TemplateRenderer.js +231 -0
- package/src/htmx/browserRuntime.js +97 -0
- package/src/htmx/index.js +10 -0
- package/src/htmx/metadata.js +349 -0
- package/src/htmx/sourceRoot.js +28 -0
- package/src/htmx/start.js +17 -0
- package/src/htmx/synchronous.js +9 -0
- package/src/http/BaseHttpServer.js +82 -117
- package/src/http/HttpServer.js +18 -18
- package/src/http/HttpsServer.js +20 -20
- package/src/serverLifecycle.js +46 -46
- package/src/ws/AdmissionPolicy.js +145 -0
- package/src/ws/BaseHandler.js +40 -40
- package/src/ws/BaseSocketServer.js +199 -100
- package/src/ws/DefaultHandler.js +5 -5
- package/src/ws/DefaultRoute.js +8 -8
- 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 -9
- package/src/ws/SessionRegistry.js +135 -0
- package/src/ws/SocketRoute.js +523 -254
- package/src/ws/SocketServer.js +8 -8
- 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 -33
- package/src/ws/util.js +38 -30
- package/src/htmx/HtmxRenderer.js +0 -73
- package/src/htmx/RedWebHtmxComponent.js +0 -11
|
@@ -1,31 +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
|
-
*/
|
|
8
|
-
|
|
9
|
-
const DefaultRoute = require('./DefaultRoute');
|
|
10
|
-
const {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
|
|
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');
|
|
19
|
+
|
|
20
|
+
const SOCKET_OPTIONS = {
|
|
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
|
+
};
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Base WebSocket server
|
|
@@ -35,56 +37,68 @@ class BaseSocketServer {
|
|
|
35
37
|
* @param {import('http').Server} server
|
|
36
38
|
* @param {SocketServerOptions} [options]
|
|
37
39
|
*/
|
|
38
|
-
constructor(server, options = {}, ownsServer = false, name = 'SocketServer') {
|
|
39
|
-
if (!server || typeof server.on !== 'function') throw new TypeError('A Node HTTP(S) server is required.');
|
|
40
|
-
Object.assign(this, { ...SOCKET_OPTIONS, ...options });
|
|
41
|
-
validateListenerOptions(this);
|
|
42
|
-
if (!Array.isArray(this.routes)) throw new TypeError('`routes` must be an array.');
|
|
43
|
-
this.server = server;
|
|
44
|
-
this.ownsServer = ownsServer;
|
|
45
|
-
this.closeServerOnShutdown = options.closeServerOnShutdown ?? ownsServer;
|
|
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.');
|
|
42
|
+
Object.assign(this, { ...SOCKET_OPTIONS, ...options });
|
|
43
|
+
validateListenerOptions(this);
|
|
44
|
+
if (!Array.isArray(this.routes)) throw new TypeError('`routes` must be an array.');
|
|
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);
|
|
46
56
|
|
|
47
57
|
/* ─── ROUTE INITIALISATION ─────────────────────────── */
|
|
48
|
-
const RouteClasses = options.routes?.length ? [...options.routes] : [DefaultRoute];
|
|
49
|
-
this.routes = [];
|
|
50
|
-
try {
|
|
51
|
-
for (const RouteClass of RouteClasses) {
|
|
52
|
-
const route = new RouteClass(server, { logger: this.logger });
|
|
53
|
-
if (this.routes.some(existing => existing.path === route.path)) {
|
|
54
|
-
this.disposeRoutes([route]);
|
|
55
|
-
throw new Error('WebSocket route paths must be unique.');
|
|
56
|
-
}
|
|
57
|
-
this.routes.push(route);
|
|
58
|
-
}
|
|
59
|
-
} catch (error) {
|
|
60
|
-
this.disposeRoutes(this.routes);
|
|
61
|
-
throw error;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
this._upgradeHandler = this.handleUpgrade.bind(this);
|
|
65
|
-
this.server.on('upgrade', this._upgradeHandler);
|
|
66
|
-
|
|
67
|
-
const shouldListen = (ownsServer && this.listen !== false) || (!ownsServer && options.listen === true);
|
|
68
|
-
if (shouldListen) {
|
|
69
|
-
listenServer(this.server, {
|
|
70
|
-
port: this.port,
|
|
71
|
-
bind: this.bind,
|
|
72
|
-
callback: this.listenCallback,
|
|
73
|
-
logger: this.logger,
|
|
74
|
-
name,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
disposeRoutes(routes) {
|
|
80
|
-
routes.forEach(route => {
|
|
81
|
-
Promise.resolve()
|
|
82
|
-
.then(() => route.shutdown?.())
|
|
83
|
-
.catch(error => this.logger?.error?.('Error shutting down route:', error));
|
|
84
|
-
});
|
|
85
|
-
}
|
|
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);
|
|
76
|
+
|
|
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
|
+
});
|
|
95
|
+
}
|
|
86
96
|
|
|
87
97
|
handleUpgrade(req, sock, head) {
|
|
98
|
+
// Upgrade sockets are detached from Node's HTTP request lifecycle. A peer
|
|
99
|
+
// may reset one while admission is pending or after a rejection response;
|
|
100
|
+
// consume that transport-level event so it cannot crash the process.
|
|
101
|
+
if (typeof sock.on === 'function') sock.on('error', Function.prototype);
|
|
88
102
|
// Some websocket clients (e.g., certain UE plugins) are finicky about the
|
|
89
103
|
// HTTP upgrade path they send. Normalise the path and fall back to a default
|
|
90
104
|
// route so we can still complete the upgrade instead of tearing the socket down.
|
|
@@ -97,50 +111,135 @@ class BaseSocketServer {
|
|
|
97
111
|
})();
|
|
98
112
|
|
|
99
113
|
const route =
|
|
100
|
-
this.routes.find(r => r.path === path) ||
|
|
101
|
-
(this.fallbackToRoot ? this.routes.find(r => r.path === '/') : undefined);
|
|
114
|
+
this.routes.find(r => r.path === path) ||
|
|
115
|
+
(this.fallbackToRoot ? this.routes.find(r => r.path === '/') : undefined);
|
|
102
116
|
|
|
103
117
|
if (!route) return sock.destroy();
|
|
118
|
+
if (this.draining) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
|
|
119
|
+
if (route.isReady?.() === false) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
|
|
120
|
+
|
|
121
|
+
if (route.admissionPolicy || route.protocolPolicy || route.transportPolicy && route.transportPolicy.maxConnections !== Infinity) {
|
|
122
|
+
let reservation;
|
|
123
|
+
try {
|
|
124
|
+
reservation = route.reserveUpgrade(req);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
this.logger?.error?.('WebSocket admission reservation failed:', error);
|
|
127
|
+
}
|
|
128
|
+
if (!reservation) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
this.pendingUpgrades.set(sock, { controller, route, reservation });
|
|
131
|
+
void Promise.resolve()
|
|
132
|
+
.then(() => route.authorizeUpgrade(req, sock, controller.signal))
|
|
133
|
+
.then(accepted => {
|
|
134
|
+
if (sock.destroyed) return;
|
|
135
|
+
if (this.draining) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
|
|
136
|
+
if (!accepted) {
|
|
137
|
+
const redirect = req[PLACEMENT_REDIRECT];
|
|
138
|
+
if (redirect) return this.rejectUpgrade(sock, 307, 'Temporary Redirect', { Location: redirect });
|
|
139
|
+
const rejection = req[PROTOCOL_REJECTION];
|
|
140
|
+
return rejection
|
|
141
|
+
? this.rejectUpgrade(sock, rejection.statusCode, rejection.statusText, rejection.headers)
|
|
142
|
+
: this.rejectUpgrade(sock, 401, 'Unauthorized');
|
|
143
|
+
}
|
|
144
|
+
this.completeUpgrade(route, req, sock, head);
|
|
145
|
+
})
|
|
146
|
+
.catch(error => {
|
|
147
|
+
this.logger?.error?.('WebSocket admission failed:', error);
|
|
148
|
+
if (!sock.destroyed) this.rejectUpgrade(sock, 401, 'Unauthorized');
|
|
149
|
+
})
|
|
150
|
+
.finally(async () => {
|
|
151
|
+
await req[ADMISSION_SETTLEMENT];
|
|
152
|
+
this.pendingUpgrades.delete(sock);
|
|
153
|
+
route.releaseUpgrade(reservation);
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.completeUpgrade(route, req, sock, head);
|
|
159
|
+
}
|
|
104
160
|
|
|
161
|
+
completeUpgrade(route, req, sock, head) {
|
|
105
162
|
route.server.handleUpgrade(req, sock, head, (s, r) =>
|
|
106
163
|
route.server.emit('connection', s, r)
|
|
107
164
|
);
|
|
108
165
|
}
|
|
109
166
|
|
|
167
|
+
rejectUpgrade(socket, statusCode, statusText, headers = {}) {
|
|
168
|
+
try {
|
|
169
|
+
const extraHeaders = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join('');
|
|
170
|
+
socket.end?.(
|
|
171
|
+
`HTTP/1.1 ${statusCode} ${statusText}\r\n` +
|
|
172
|
+
extraHeaders +
|
|
173
|
+
'Connection: close\r\n' +
|
|
174
|
+
'Content-Length: 0\r\n\r\n'
|
|
175
|
+
);
|
|
176
|
+
} catch {
|
|
177
|
+
socket.destroy?.();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
110
181
|
/**
|
|
111
182
|
* Dynamically attach a new route at runtime
|
|
112
183
|
* @param {new () => import('./SocketRoute').SocketRoute} RouteClass
|
|
113
184
|
*/
|
|
114
|
-
addRoute(RouteClass) {
|
|
115
|
-
const route = new RouteClass(this.server, { logger: this.logger });
|
|
116
|
-
if (this.routes.some(existing => existing.path === route.path)) {
|
|
117
|
-
this.disposeRoutes([route]);
|
|
118
|
-
throw new Error(`A WebSocket route already exists at ${route.path}.`);
|
|
119
|
-
}
|
|
120
|
-
this.routes.push(route);
|
|
121
|
-
return route;
|
|
185
|
+
addRoute(RouteClass) {
|
|
186
|
+
const route = new RouteClass(this.server, { logger: this.logger });
|
|
187
|
+
if (this.routes.some(existing => existing.path === route.path)) {
|
|
188
|
+
this.disposeRoutes([route]);
|
|
189
|
+
throw new Error(`A WebSocket route already exists at ${route.path}.`);
|
|
190
|
+
}
|
|
191
|
+
this.routes.push(route);
|
|
192
|
+
return route;
|
|
122
193
|
}
|
|
123
194
|
|
|
124
195
|
/**
|
|
125
196
|
* Gracefully tear down all routes (and their services)
|
|
126
197
|
*/
|
|
127
|
-
shutdown() {
|
|
128
|
-
if (!this._shutdownPromise) this._shutdownPromise = this.performShutdown();
|
|
129
|
-
return this._shutdownPromise;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
this.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
198
|
+
shutdown() {
|
|
199
|
+
if (!this._shutdownPromise) this._shutdownPromise = this.performShutdown();
|
|
200
|
+
return this._shutdownPromise;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
isReady() {
|
|
204
|
+
return !this.draining && this.routes.every(route => route.isReady?.() !== false);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
beginDrain() {
|
|
208
|
+
if (this.draining) return false;
|
|
209
|
+
this.draining = true;
|
|
210
|
+
this.pendingUpgrades.forEach(({ controller }) => controller.abort());
|
|
211
|
+
this.routes.forEach(route => route.beginDrain?.());
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async performShutdown() {
|
|
216
|
+
this.beginDrain();
|
|
217
|
+
this.server.off?.('upgrade', this._upgradeHandler);
|
|
218
|
+
const deadline = Date.now() + Math.max(0, ...this.routes.map(route => route.shutdownTimeoutMs));
|
|
219
|
+
const errors = await settleTasks(this.routes.map(route => () => route.shutdown?.()));
|
|
220
|
+
if (this.closeServerOnShutdown && this.server.listening) {
|
|
221
|
+
try {
|
|
222
|
+
await this.closeOwnedServer(Math.max(0, deadline - Date.now()));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
errors.push(error);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (this._connectionHandler) this.server.off?.('connection', this._connectionHandler);
|
|
228
|
+
throwCleanupErrors(errors, 'One or more WebSocket server cleanup operations failed.');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
closeOwnedServer(timeoutMs) {
|
|
232
|
+
let timer;
|
|
233
|
+
const closing = closeServer(this.server);
|
|
234
|
+
const timeout = new Promise(resolve => {
|
|
235
|
+
timer = setTimeout(() => {
|
|
236
|
+
this.rawConnections?.forEach(socket => socket.destroy?.());
|
|
237
|
+
resolve();
|
|
238
|
+
}, timeoutMs);
|
|
239
|
+
timer.unref?.();
|
|
240
|
+
});
|
|
241
|
+
return Promise.race([closing, timeout]).finally(() => clearTimeout(timer));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
145
244
|
|
|
146
245
|
module.exports = { BaseSocketServer, SOCKET_OPTIONS };
|
package/src/ws/DefaultHandler.js
CHANGED
|
@@ -5,9 +5,9 @@ class DefaultHandler extends BaseHandler {
|
|
|
5
5
|
super("DefaultHandler");
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
onMessage(socket, message) {
|
|
9
|
-
socket.sendJson({ message: `I got your message of ${JSON.stringify(message)}` });
|
|
10
|
-
}
|
|
11
|
-
}
|
|
8
|
+
onMessage(socket, message) {
|
|
9
|
+
socket.sendJson({ message: `I got your message of ${JSON.stringify(message)}` });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
12
|
|
|
13
|
-
module.exports = DefaultHandler;
|
|
13
|
+
module.exports = DefaultHandler;
|
package/src/ws/DefaultRoute.js
CHANGED
|
@@ -2,14 +2,14 @@ const SocketRoute = require("./SocketRoute");
|
|
|
2
2
|
const DefaultHandler = require('./DefaultHandler');
|
|
3
3
|
|
|
4
4
|
class DefaultRoute extends SocketRoute {
|
|
5
|
-
constructor(server, options = {}) {
|
|
6
|
-
super({
|
|
7
|
-
server,
|
|
8
|
-
path: "/",
|
|
9
|
-
handlers: [DefaultHandler],
|
|
10
|
-
logger: options.logger,
|
|
11
|
-
})
|
|
5
|
+
constructor(server, options = {}) {
|
|
6
|
+
super({
|
|
7
|
+
server,
|
|
8
|
+
path: "/",
|
|
9
|
+
handlers: [DefaultHandler],
|
|
10
|
+
logger: options.logger,
|
|
11
|
+
})
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
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;
|