redweb 0.8.0 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +458 -307
  3. package/client.d.ts +42 -0
  4. package/client.js +55 -0
  5. package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
  6. package/docs/PRODUCTION_READINESS.md +68 -0
  7. package/docs/VERIFICATION_EVIDENCE.md +20 -0
  8. package/index.d.ts +320 -114
  9. package/index.js +27 -12
  10. package/package.json +28 -15
  11. package/src/htmx/HtmxRenderer.js +13 -13
  12. package/src/http/BaseHttpServer.js +112 -112
  13. package/src/http/HttpServer.js +18 -18
  14. package/src/http/HttpsServer.js +20 -20
  15. package/src/serverLifecycle.js +46 -46
  16. package/src/ws/AdmissionPolicy.js +145 -0
  17. package/src/ws/BaseHandler.js +40 -40
  18. package/src/ws/BaseSocketServer.js +195 -100
  19. package/src/ws/DefaultHandler.js +5 -5
  20. package/src/ws/DefaultRoute.js +8 -8
  21. package/src/ws/DistributionBridge.js +271 -0
  22. package/src/ws/FixedStepService.js +74 -0
  23. package/src/ws/HeartbeatMonitor.js +75 -0
  24. package/src/ws/Metrics.js +34 -0
  25. package/src/ws/ProtocolPolicy.js +130 -0
  26. package/src/ws/RoomRegistry.js +117 -0
  27. package/src/ws/RouteRuntime.js +146 -0
  28. package/src/ws/SecureSocketServer.js +9 -9
  29. package/src/ws/SessionRegistry.js +135 -0
  30. package/src/ws/SocketRoute.js +523 -254
  31. package/src/ws/SocketServer.js +8 -8
  32. package/src/ws/TaskQueue.js +64 -0
  33. package/src/ws/TokenBucket.js +31 -0
  34. package/src/ws/TransportPolicy.js +68 -0
  35. package/src/ws/index.js +7 -2
  36. package/src/ws/protocol-schema.json +13 -0
  37. package/src/ws/protocol-validation.js +21 -0
  38. package/src/ws/shutdown.js +33 -33
  39. package/src/ws/util.js +38 -30
@@ -18,47 +18,47 @@ class BaseHandler {
18
18
  * @param {WebSocket & {sendJson: (message: Object) => void, broadcast: (message: Object) => void}} socket - The WebSocket connection that sent the message.
19
19
  * @param {any} message - The incoming message in parsed JSON.
20
20
  */
21
- async handleMessage(socket, message) {
22
- const validationResult = await this.validateMessage(message, socket);
23
- if (validationResult === false) {
24
- throw new Error('Invalid message');
25
- }
26
- return this.onMessage(socket, message);
27
- }
28
-
29
- validateMessage() {
30
- return true;
31
- }
32
-
33
- /**
34
- * Handles an incoming binary message.
35
- * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
36
- * @param {Buffer} buffer - The incoming binary message.
37
- */
38
- async handleBinaryMessage(socket, buffer) {
39
- return this.onBinaryMessage(socket, buffer);
40
- }
41
-
42
- /**
43
- * Method to be overriden to process messages.
44
- * @param {WebSocket} socket - The WebSocket connection that sent the message.
45
- * @param {any} message - The incoming message in parsed JSON.
21
+ async handleMessage(socket, message) {
22
+ const validationResult = await this.validateMessage(message, socket);
23
+ if (validationResult === false) {
24
+ throw new Error('Invalid message');
25
+ }
26
+ return this.onMessage(socket, message);
27
+ }
28
+
29
+ validateMessage() {
30
+ return true;
31
+ }
32
+
33
+ /**
34
+ * Handles an incoming binary message.
35
+ * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
36
+ * @param {Buffer} buffer - The incoming binary message.
46
37
  */
47
- onMessage(socket, message) {
48
- throw new Error('onMessage must be implemented by the handler.');
49
- }
50
-
51
- /**
52
- * Method to be overriden to process binary messages.
53
- * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
54
- * @param {Buffer} buffer - The incoming binary message.
55
- */
56
- onBinaryMessage(socket, buffer) {
57
- socket.sendJson({ error: 'Binary messages are not supported by this handler' });
58
- }
59
-
60
- onInitialContact(socket) {
61
-
38
+ async handleBinaryMessage(socket, buffer) {
39
+ return this.onBinaryMessage(socket, buffer);
40
+ }
41
+
42
+ /**
43
+ * Method to be overriden to process messages.
44
+ * @param {WebSocket} socket - The WebSocket connection that sent the message.
45
+ * @param {any} message - The incoming message in parsed JSON.
46
+ */
47
+ onMessage(socket, message) {
48
+ throw new Error('onMessage must be implemented by the handler.');
49
+ }
50
+
51
+ /**
52
+ * Method to be overriden to process binary messages.
53
+ * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
54
+ * @param {Buffer} buffer - The incoming binary message.
55
+ */
56
+ onBinaryMessage(socket, buffer) {
57
+ socket.sendJson({ error: 'Binary messages are not supported by this handler' });
58
+ }
59
+
60
+ onInitialContact(socket) {
61
+
62
62
  }
63
63
  }
64
64
 
@@ -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
- listenServer,
12
- closeServer,
13
- settleTasks,
14
- throwCleanupErrors,
15
- validateListenerOptions,
16
- } = require('../serverLifecycle');
17
-
18
- const SOCKET_OPTIONS = {
19
- port: 3000,
20
- bind: '0.0.0.0',
21
- ssl: null,
22
- listen: true,
23
- routes: [],
24
- fallbackToRoot: false,
25
- closeServerOnShutdown: undefined,
26
- logger: console,
27
- listenCallback: undefined,
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,54 +37,62 @@ 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) {
88
98
  // Some websocket clients (e.g., certain UE plugins) are finicky about the
@@ -97,50 +107,135 @@ class BaseSocketServer {
97
107
  })();
98
108
 
99
109
  const route =
100
- this.routes.find(r => r.path === path) ||
101
- (this.fallbackToRoot ? this.routes.find(r => r.path === '/') : undefined);
110
+ this.routes.find(r => r.path === path) ||
111
+ (this.fallbackToRoot ? this.routes.find(r => r.path === '/') : undefined);
102
112
 
103
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');
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
+ }
104
156
 
157
+ completeUpgrade(route, req, sock, head) {
105
158
  route.server.handleUpgrade(req, sock, head, (s, r) =>
106
159
  route.server.emit('connection', s, r)
107
160
  );
108
161
  }
109
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
+
110
177
  /**
111
178
  * Dynamically attach a new route at runtime
112
179
  * @param {new () => import('./SocketRoute').SocketRoute} RouteClass
113
180
  */
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;
181
+ addRoute(RouteClass) {
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;
122
189
  }
123
190
 
124
191
  /**
125
192
  * Gracefully tear down all routes (and their services)
126
193
  */
127
- shutdown() {
128
- if (!this._shutdownPromise) this._shutdownPromise = this.performShutdown();
129
- return this._shutdownPromise;
130
- }
131
-
132
- async performShutdown() {
133
- this.server.off?.('upgrade', this._upgradeHandler);
134
- const errors = await settleTasks(this.routes.map(route => () => route.shutdown?.()));
135
- if (this.closeServerOnShutdown && this.server.listening) {
136
- try {
137
- await closeServer(this.server);
138
- } catch (error) {
139
- errors.push(error);
140
- }
141
- }
142
- throwCleanupErrors(errors, 'One or more WebSocket server cleanup operations failed.');
143
- }
144
- }
194
+ shutdown() {
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));
238
+ }
239
+ }
145
240
 
146
241
  module.exports = { BaseSocketServer, SOCKET_OPTIONS };
@@ -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;
@@ -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;