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.
Files changed (72) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +573 -307
  3. package/client.d.ts +42 -0
  4. package/client.js +55 -0
  5. package/docs/LIVE_HTML.md +313 -0
  6. package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
  7. package/docs/PRODUCTION_READINESS.md +68 -0
  8. package/docs/VERIFICATION_EVIDENCE.md +20 -0
  9. package/examples/live-html/cards.css +36 -0
  10. package/examples/live-html/cards.html +11 -0
  11. package/examples/live-html/cards.js +91 -0
  12. package/examples/live-html/cards.ts +35 -0
  13. package/examples/live-html/chatroom.css +156 -0
  14. package/examples/live-html/chatroom.js +268 -0
  15. package/examples/live-html/chatroom.ts +217 -0
  16. package/examples/live-html/components.css +7 -0
  17. package/examples/live-html/components.js +113 -0
  18. package/examples/live-html/components.ts +41 -0
  19. package/examples/live-html/counter.css +24 -0
  20. package/examples/live-html/counter.html +10 -0
  21. package/examples/live-html/counter.js +73 -0
  22. package/examples/live-html/counter.ts +21 -0
  23. package/examples/live-html/tsconfig.json +16 -0
  24. package/index.d.ts +538 -114
  25. package/index.js +44 -12
  26. package/package.json +39 -15
  27. package/src/htmx/Html.js +133 -0
  28. package/src/htmx/HtmlRenderer.js +88 -0
  29. package/src/htmx/HtmlSyntax.js +168 -0
  30. package/src/htmx/LiveHtmlServer.js +91 -0
  31. package/src/htmx/LivePage.js +232 -0
  32. package/src/htmx/PageAssetLoader.js +34 -0
  33. package/src/htmx/PageManager.js +435 -0
  34. package/src/htmx/StaticExporter.js +78 -0
  35. package/src/htmx/StaticSite.js +182 -0
  36. package/src/htmx/TemplateRenderer.js +231 -0
  37. package/src/htmx/browserRuntime.js +97 -0
  38. package/src/htmx/index.js +10 -0
  39. package/src/htmx/metadata.js +349 -0
  40. package/src/htmx/sourceRoot.js +28 -0
  41. package/src/htmx/start.js +17 -0
  42. package/src/htmx/synchronous.js +9 -0
  43. package/src/http/BaseHttpServer.js +82 -117
  44. package/src/http/HttpServer.js +18 -18
  45. package/src/http/HttpsServer.js +20 -20
  46. package/src/serverLifecycle.js +46 -46
  47. package/src/ws/AdmissionPolicy.js +145 -0
  48. package/src/ws/BaseHandler.js +40 -40
  49. package/src/ws/BaseSocketServer.js +199 -100
  50. package/src/ws/DefaultHandler.js +5 -5
  51. package/src/ws/DefaultRoute.js +8 -8
  52. package/src/ws/DistributionBridge.js +271 -0
  53. package/src/ws/FixedStepService.js +74 -0
  54. package/src/ws/HeartbeatMonitor.js +75 -0
  55. package/src/ws/Metrics.js +34 -0
  56. package/src/ws/ProtocolPolicy.js +130 -0
  57. package/src/ws/RoomRegistry.js +117 -0
  58. package/src/ws/RouteRuntime.js +146 -0
  59. package/src/ws/SecureSocketServer.js +9 -9
  60. package/src/ws/SessionRegistry.js +135 -0
  61. package/src/ws/SocketRoute.js +523 -254
  62. package/src/ws/SocketServer.js +8 -8
  63. package/src/ws/TaskQueue.js +64 -0
  64. package/src/ws/TokenBucket.js +31 -0
  65. package/src/ws/TransportPolicy.js +68 -0
  66. package/src/ws/index.js +7 -2
  67. package/src/ws/protocol-schema.json +13 -0
  68. package/src/ws/protocol-validation.js +21 -0
  69. package/src/ws/shutdown.js +33 -33
  70. package/src/ws/util.js +38 -30
  71. package/src/htmx/HtmxRenderer.js +0 -73
  72. package/src/htmx/RedWebHtmxComponent.js +0 -11
@@ -1,26 +1,26 @@
1
- const http = require('http');
2
- const { BaseHttpServer } = require('./BaseHttpServer');
3
- const { listenServer, closeServer } = require('../serverLifecycle');
1
+ const http = require('http');
2
+ const { BaseHttpServer } = require('./BaseHttpServer');
3
+ const { listenServer, closeServer } = require('../serverLifecycle');
4
4
 
5
5
  /**
6
6
  * HTTP Server
7
7
  * @param {RedWebOptions} options - Configuration options for RedWeb.
8
8
  * @return {Object} Express application instance.
9
9
  */
10
- function HttpServer(options) {
11
- BaseHttpServer.call(this, options);
12
- this.server = http.createServer(this.app);
13
- this.shutdown = () => closeServer(this.server);
14
- if (this.listen !== false) {
15
- listenServer(this.server, {
16
- port: this.port,
17
- bind: this.bind,
18
- callback: this.listenCallback,
19
- logger: this.logger,
20
- name: 'HttpServer',
21
- });
22
- }
23
- return this;
24
- }
10
+ function HttpServer(options) {
11
+ BaseHttpServer.call(this, options);
12
+ this.server = http.createServer(this.app);
13
+ this.shutdown = () => closeServer(this.server);
14
+ if (this.listen !== false) {
15
+ listenServer(this.server, {
16
+ port: this.port,
17
+ bind: this.bind,
18
+ callback: this.listenCallback,
19
+ logger: this.logger,
20
+ name: 'HttpServer',
21
+ });
22
+ }
23
+ return this;
24
+ }
25
25
 
26
26
  module.exports = HttpServer;
@@ -1,30 +1,30 @@
1
1
  const https = require('https');
2
2
  const { BaseHttpServer } = require('./BaseHttpServer');
3
- const loadSslConfig = require('../sslConfig');
4
- const { listenServer, closeServer } = require('../serverLifecycle');
3
+ const loadSslConfig = require('../sslConfig');
4
+ const { listenServer, closeServer } = require('../serverLifecycle');
5
5
 
6
6
  /**
7
7
  * HTTPS Server
8
8
  * @param {RedWebOptions} options - Configuration options for RedWeb.
9
9
  * @return {Object} Express application instance.
10
10
  */
11
- function HttpsServer(options) {
12
- BaseHttpServer.call(this, options);
13
- const sslOptions = loadSslConfig(this.ssl);
14
- this.server = https.createServer(sslOptions, this.app);
15
- this.shutdown = () => closeServer(this.server);
16
- if (this.listen === false) {
17
- return this;
18
- }
19
-
20
- listenServer(this.server, {
21
- port: this.port,
22
- bind: this.bind,
23
- callback: this.listenCallback,
24
- logger: this.logger,
25
- name: 'HttpsServer',
26
- });
27
- return this;
28
- }
11
+ function HttpsServer(options) {
12
+ BaseHttpServer.call(this, options);
13
+ const sslOptions = loadSslConfig(this.ssl);
14
+ this.server = https.createServer(sslOptions, this.app);
15
+ this.shutdown = () => closeServer(this.server);
16
+ if (this.listen === false) {
17
+ return this;
18
+ }
19
+
20
+ listenServer(this.server, {
21
+ port: this.port,
22
+ bind: this.bind,
23
+ callback: this.listenCallback,
24
+ logger: this.logger,
25
+ name: 'HttpsServer',
26
+ });
27
+ return this;
28
+ }
29
29
 
30
30
  module.exports = HttpsServer;
@@ -1,46 +1,46 @@
1
- function listenServer(server, { port, bind, callback, logger = console, name = 'Server' }) {
2
- const onListening = callback || (() => logger?.log?.(`RedWeb ${name} listening on ${bind}:${port}`));
3
- server.listen(port, bind, onListening);
4
- }
5
-
6
- function validateListenerOptions(options) {
7
- if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
8
- throw new TypeError('`port` must be an integer between 0 and 65535.');
9
- }
10
- if (typeof options.bind !== 'string' || !options.bind) {
11
- throw new TypeError('`bind` must be a non-empty string.');
12
- }
13
- if (typeof options.listen !== 'boolean') {
14
- throw new TypeError('`listen` must be a boolean.');
15
- }
16
- if (options.listenCallback !== undefined && typeof options.listenCallback !== 'function') {
17
- throw new TypeError('`listenCallback` must be a function.');
18
- }
19
- }
20
-
21
- async function settleTasks(tasks) {
22
- const results = await Promise.allSettled(tasks.map(task => Promise.resolve().then(task)));
23
- return results.filter(result => result.status === 'rejected').map(result => result.reason);
24
- }
25
-
26
- function throwCleanupErrors(errors, message) {
27
- if (!errors.length) return;
28
- const aggregate = new Error(message);
29
- aggregate.errors = errors;
30
- throw aggregate;
31
- }
32
-
33
- function closeServer(server) {
34
- return new Promise((resolve, reject) => {
35
- if (!server?.listening) return resolve();
36
- server.close((error) => error ? reject(error) : resolve());
37
- });
38
- }
39
-
40
- module.exports = {
41
- listenServer,
42
- closeServer,
43
- settleTasks,
44
- throwCleanupErrors,
45
- validateListenerOptions,
46
- };
1
+ function listenServer(server, { port, bind, callback, logger = console, name = 'Server' }) {
2
+ const onListening = callback || (() => logger?.log?.(`RedWeb ${name} listening on ${bind}:${port}`));
3
+ server.listen(port, bind, onListening);
4
+ }
5
+
6
+ function validateListenerOptions(options) {
7
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
8
+ throw new TypeError('`port` must be an integer between 0 and 65535.');
9
+ }
10
+ if (typeof options.bind !== 'string' || !options.bind) {
11
+ throw new TypeError('`bind` must be a non-empty string.');
12
+ }
13
+ if (typeof options.listen !== 'boolean') {
14
+ throw new TypeError('`listen` must be a boolean.');
15
+ }
16
+ if (options.listenCallback !== undefined && typeof options.listenCallback !== 'function') {
17
+ throw new TypeError('`listenCallback` must be a function.');
18
+ }
19
+ }
20
+
21
+ async function settleTasks(tasks) {
22
+ const results = await Promise.allSettled(tasks.map(task => Promise.resolve().then(task)));
23
+ return results.filter(result => result.status === 'rejected').map(result => result.reason);
24
+ }
25
+
26
+ function throwCleanupErrors(errors, message) {
27
+ if (!errors.length) return;
28
+ const aggregate = new Error(message);
29
+ aggregate.errors = errors;
30
+ throw aggregate;
31
+ }
32
+
33
+ function closeServer(server) {
34
+ return new Promise((resolve, reject) => {
35
+ if (!server?.listening) return resolve();
36
+ server.close((error) => error ? reject(error) : resolve());
37
+ });
38
+ }
39
+
40
+ module.exports = {
41
+ listenServer,
42
+ closeServer,
43
+ settleTasks,
44
+ throwCleanupErrors,
45
+ validateListenerOptions,
46
+ };
@@ -0,0 +1,145 @@
1
+ const ADMISSION_CONTEXT = Symbol('redweb.admissionContext');
2
+ const PLACEMENT_REDIRECT = Symbol('redweb.placementRedirect');
3
+ const ADMISSION_SETTLEMENT = Symbol('redweb.admissionSettlement');
4
+
5
+ class AdmissionPolicy {
6
+ constructor(options) {
7
+ const config = typeof options === 'function' ? { authenticate: options } : options;
8
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
9
+ throw new TypeError('`admission` must be a function or an object.');
10
+ }
11
+ const {
12
+ authenticate,
13
+ origins,
14
+ place,
15
+ timeoutMs = 5000,
16
+ allowedPlacementOrigins,
17
+ allowInsecurePlacement = false,
18
+ } = config;
19
+ if (authenticate !== undefined && typeof authenticate !== 'function') {
20
+ throw new TypeError('`admission.authenticate` must be a function.');
21
+ }
22
+ if (!(Array.isArray(origins) || typeof origins === 'function' || origins === undefined)) {
23
+ throw new TypeError('`admission.origins` must be an array or a function.');
24
+ }
25
+ if (place !== undefined && typeof place !== 'function') {
26
+ throw new TypeError('`admission.place` must be a function.');
27
+ }
28
+ if (Array.isArray(origins) && origins.some(origin => typeof origin !== 'string' || !origin)) {
29
+ throw new TypeError('Every `admission.origins` entry must be a non-empty string.');
30
+ }
31
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1) {
32
+ throw new TypeError('`admission.timeoutMs` must be a positive integer.');
33
+ }
34
+ if (typeof allowInsecurePlacement !== 'boolean') {
35
+ throw new TypeError('`admission.allowInsecurePlacement` must be a boolean.');
36
+ }
37
+ if (allowedPlacementOrigins !== undefined && !Array.isArray(allowedPlacementOrigins)) {
38
+ throw new TypeError('`admission.allowedPlacementOrigins` must be an array.');
39
+ }
40
+ this.allowedPlacementOrigins = allowedPlacementOrigins?.map(origin => this.validatePlacementOrigin(origin));
41
+ if (this.allowedPlacementOrigins && new Set(this.allowedPlacementOrigins).size !== this.allowedPlacementOrigins.length) {
42
+ throw new TypeError('`admission.allowedPlacementOrigins` entries must be unique.');
43
+ }
44
+ if (!authenticate && !origins && !place) {
45
+ throw new TypeError('`admission` requires `authenticate`, `origins`, or `place`.');
46
+ }
47
+ this.authenticate = authenticate;
48
+ this.origins = origins;
49
+ this.place = place;
50
+ this.timeoutMs = timeoutMs;
51
+ this.allowInsecurePlacement = allowInsecurePlacement;
52
+ }
53
+
54
+ async authorize(request, rawSocket, route, externalSignal) {
55
+ const controller = new AbortController();
56
+ const onClose = () => controller.abort();
57
+ const onExternalAbort = () => controller.abort();
58
+ rawSocket.once('close', onClose);
59
+ if (externalSignal?.aborted) controller.abort();
60
+ else externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
61
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
62
+ timer.unref();
63
+ try {
64
+ const evaluation = Promise.resolve().then(() => this.evaluate(request, route, controller.signal));
65
+ request[ADMISSION_SETTLEMENT] = evaluation.then(() => undefined, () => undefined);
66
+ const cancelled = new Promise((_, reject) => {
67
+ controller.signal.addEventListener('abort', () => reject(new Error('Admission cancelled.')), { once: true });
68
+ });
69
+ const result = await Promise.race([
70
+ evaluation,
71
+ cancelled,
72
+ ]);
73
+ if (result === false || rawSocket.destroyed || controller.signal.aborted) return false;
74
+ if (result.redirect) {
75
+ request[PLACEMENT_REDIRECT] = result.redirect;
76
+ return false;
77
+ }
78
+ request[ADMISSION_CONTEXT] = { principal: result.principal };
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ } finally {
83
+ clearTimeout(timer);
84
+ rawSocket.off?.('close', onClose);
85
+ externalSignal?.removeEventListener?.('abort', onExternalAbort);
86
+ }
87
+ }
88
+
89
+ validatePlacementOrigin(origin) {
90
+ if (typeof origin !== 'string' || !origin) {
91
+ throw new TypeError('Placement origins must be non-empty strings.');
92
+ }
93
+ let parsed;
94
+ try {
95
+ parsed = new URL(origin);
96
+ } catch {
97
+ throw new TypeError('Placement origins must be valid ws or wss origins.');
98
+ }
99
+ if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
100
+ throw new TypeError('Placement origins must be ws or wss origins without credentials, paths, queries, or fragments.');
101
+ }
102
+ return parsed.origin;
103
+ }
104
+
105
+ isSafeRedirect(value) {
106
+ if (!value || value.length > 2048 || /[\r\n]/.test(value)) return false;
107
+ try {
108
+ const parsed = new URL(value);
109
+ if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) return false;
110
+ if (parsed.protocol === 'ws:' && !this.allowInsecurePlacement) return false;
111
+ return !this.allowedPlacementOrigins || this.allowedPlacementOrigins.includes(parsed.origin);
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ async evaluate(request, route, signal) {
118
+ if (!await this.acceptsOrigin(request)) return false;
119
+ const context = {
120
+ signal,
121
+ networkIdentity: route.resolveRemoteAddress(request),
122
+ route,
123
+ };
124
+ const principal = this.authenticate ? await this.authenticate(request, context) : undefined;
125
+ if (principal === false) return false;
126
+ if (!this.place) return { principal };
127
+ const placement = await this.place(principal, request, context);
128
+ if (placement === false) return false;
129
+ if (typeof placement === 'string') {
130
+ return this.isSafeRedirect(placement) ? { redirect: placement } : false;
131
+ }
132
+ return { principal };
133
+ }
134
+
135
+ async acceptsOrigin(request) {
136
+ if (!this.origins) return true;
137
+ const origin = request?.headers?.origin;
138
+ if (typeof this.origins === 'function') {
139
+ return Boolean(await this.origins(origin, request));
140
+ }
141
+ return typeof origin === 'string' && this.origins.includes(origin);
142
+ }
143
+ }
144
+
145
+ module.exports = { AdmissionPolicy, ADMISSION_CONTEXT, PLACEMENT_REDIRECT, ADMISSION_SETTLEMENT };
@@ -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