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
@@ -6,13 +6,13 @@ const { BaseSocketServer } = require('./BaseSocketServer');
6
6
  * @param {SocketServerOptions} options - Configuration options for SocketServer.
7
7
  * @return {Object} WebSocket server instance.
8
8
  */
9
- class SocketServer extends BaseSocketServer {
10
- constructor(options) {
11
- const ownsServer = !options?.server;
12
- const server = options?.server || http.createServer();
13
- super(server, options, ownsServer, 'SocketServer');
14
- return this;
15
- }
16
- }
9
+ class SocketServer extends BaseSocketServer {
10
+ constructor(options) {
11
+ const ownsServer = !options?.server;
12
+ const server = options?.server || http.createServer();
13
+ super(server, options, ownsServer, 'SocketServer');
14
+ return this;
15
+ }
16
+ }
17
17
 
18
18
  module.exports = SocketServer;
@@ -0,0 +1,64 @@
1
+ class TaskQueue {
2
+ constructor(maxPending, onError = () => {}, errorContext) {
3
+ if (!Number.isInteger(maxPending) || maxPending < 1) {
4
+ throw new TypeError('`maxPending` must be a positive integer.');
5
+ }
6
+ if (typeof onError !== 'function') throw new TypeError('`onError` must be a function.');
7
+ this.maxPending = maxPending;
8
+ this.onError = onError;
9
+ this.errorContext = errorContext;
10
+ this.tasks = [];
11
+ this.running = false;
12
+ this.closed = false;
13
+ this.idleWaiters = [];
14
+ }
15
+
16
+ enqueue(task) {
17
+ if (typeof task !== 'function') throw new TypeError('Queued tasks must be functions.');
18
+ if (this.closed || this.tasks.length >= this.maxPending) return false;
19
+ this.tasks.push(task);
20
+ if (!this.running) void this.drain();
21
+ return true;
22
+ }
23
+
24
+ async drain() {
25
+ this.running = true;
26
+ while (!this.closed && this.tasks.length) {
27
+ const task = this.tasks.shift();
28
+ try {
29
+ await task();
30
+ } catch (error) {
31
+ try {
32
+ await this.onError.call(this.errorContext, error);
33
+ } catch {
34
+ // Error reporting must never interrupt queue cleanup.
35
+ }
36
+ }
37
+ }
38
+ this.running = false;
39
+ this.resolveIdle();
40
+ }
41
+
42
+ close() {
43
+ if (this.closed) return;
44
+ this.closed = true;
45
+ this.tasks.length = 0;
46
+ if (!this.running) this.resolveIdle();
47
+ }
48
+
49
+ whenIdle() {
50
+ if (!this.running && !this.tasks.length) return Promise.resolve();
51
+ return new Promise(resolve => this.idleWaiters.push(resolve));
52
+ }
53
+
54
+ resolveIdle() {
55
+ const waiters = this.idleWaiters.splice(0);
56
+ waiters.forEach(resolve => resolve());
57
+ }
58
+
59
+ get pending() {
60
+ return this.tasks.length + (this.running ? 1 : 0);
61
+ }
62
+ }
63
+
64
+ module.exports = TaskQueue;
@@ -0,0 +1,31 @@
1
+ const { performance } = require('perf_hooks');
2
+
3
+ class TokenBucket {
4
+ constructor({ capacity, refillPerSecond }, clock = () => performance.now()) {
5
+ if (!Number.isFinite(capacity) || capacity <= 0) {
6
+ throw new TypeError('`capacity` must be a positive number.');
7
+ }
8
+ if (!Number.isFinite(refillPerSecond) || refillPerSecond < 0) {
9
+ throw new TypeError('`refillPerSecond` must be a non-negative number.');
10
+ }
11
+ if (typeof clock !== 'function') throw new TypeError('`clock` must be a function.');
12
+ this.capacity = capacity;
13
+ this.refillPerMs = refillPerSecond / 1000;
14
+ this.tokens = capacity;
15
+ this.clock = clock;
16
+ this.updatedAt = clock();
17
+ }
18
+
19
+ consume(cost = 1) {
20
+ if (!Number.isFinite(cost) || cost <= 0) throw new TypeError('`cost` must be a positive number.');
21
+ const now = this.clock();
22
+ const elapsed = Math.max(0, now - this.updatedAt);
23
+ this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerMs);
24
+ this.updatedAt = now;
25
+ if (this.tokens < cost) return false;
26
+ this.tokens -= cost;
27
+ return true;
28
+ }
29
+ }
30
+
31
+ module.exports = TokenBucket;
@@ -0,0 +1,68 @@
1
+ const TokenBucket = require('./TokenBucket');
2
+ const TaskQueue = require('./TaskQueue');
3
+
4
+ const RATE_ACTIONS = new Set(['drop', 'disconnect']);
5
+ const SLOW_ACTIONS = new Set(['drop', 'disconnect']);
6
+
7
+ class TransportPolicy {
8
+ constructor(limits = {}, orderedMessages = false) {
9
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
10
+ throw new TypeError('`limits` must be an object.');
11
+ }
12
+ const {
13
+ maxConnections = Infinity,
14
+ maxBufferedBytes = Infinity,
15
+ maxPendingMessages = 64,
16
+ messageRate = null,
17
+ slowConsumerAction = 'disconnect',
18
+ } = limits;
19
+ if (!(maxConnections === Infinity || Number.isInteger(maxConnections) && maxConnections > 0)) {
20
+ throw new TypeError('`limits.maxConnections` must be a positive integer.');
21
+ }
22
+ if (!(maxBufferedBytes === Infinity || Number.isInteger(maxBufferedBytes) && maxBufferedBytes >= 0)) {
23
+ throw new TypeError('`limits.maxBufferedBytes` must be a non-negative integer.');
24
+ }
25
+ if (!Number.isInteger(maxPendingMessages) || maxPendingMessages < 1) {
26
+ throw new TypeError('`limits.maxPendingMessages` must be a positive integer.');
27
+ }
28
+ if (!SLOW_ACTIONS.has(slowConsumerAction)) {
29
+ throw new TypeError('`limits.slowConsumerAction` must be "drop" or "disconnect".');
30
+ }
31
+ if (messageRate !== null) {
32
+ if (!messageRate || typeof messageRate !== 'object' || Array.isArray(messageRate)) {
33
+ throw new TypeError('`limits.messageRate` must be an object.');
34
+ }
35
+ if (!RATE_ACTIONS.has(messageRate.action ?? 'disconnect')) {
36
+ throw new TypeError('`limits.messageRate.action` must be "drop" or "disconnect".');
37
+ }
38
+ // Validate once without retaining the probe.
39
+ new TokenBucket(messageRate);
40
+ }
41
+ this.maxConnections = maxConnections;
42
+ this.maxBufferedBytes = maxBufferedBytes;
43
+ this.maxPendingMessages = maxPendingMessages;
44
+ this.messageRate = messageRate && { ...messageRate, action: messageRate.action ?? 'disconnect' };
45
+ this.slowConsumerAction = slowConsumerAction;
46
+ this.orderedMessages = Boolean(orderedMessages);
47
+ }
48
+
49
+ createRuntime(onError, errorContext) {
50
+ return {
51
+ limiter: this.messageRate ? new TokenBucket(this.messageRate) : null,
52
+ queue: this.orderedMessages ? new TaskQueue(this.maxPendingMessages, onError, errorContext) : null,
53
+ };
54
+ }
55
+
56
+ acceptsMessage(runtime) {
57
+ return runtime?.limiter ? runtime.limiter.consume() : true;
58
+ }
59
+
60
+ acceptsSend(socket, payloadBytes) {
61
+ if (this.maxBufferedBytes === Infinity) return true;
62
+ if ((socket.bufferedAmount || 0) + payloadBytes <= this.maxBufferedBytes) return true;
63
+ if (this.slowConsumerAction === 'disconnect') socket.close?.(1013, 'Slow consumer');
64
+ return false;
65
+ }
66
+ }
67
+
68
+ module.exports = TransportPolicy;
package/src/ws/index.js CHANGED
@@ -1,9 +1,14 @@
1
1
  const { SOCKET_OPTIONS } = require('./BaseSocketServer');
2
+ const { ERROR_CODES } = require('./ProtocolPolicy');
2
3
  module.exports = {
3
4
  SecureSocketServer: require('./SecureSocketServer'),
4
5
  SocketServer: require('./SocketServer'),
5
6
  SocketRoute: require('./SocketRoute'),
6
7
  SocketService: require('./SocketService'),
8
+ FixedStepService: require('./FixedStepService'),
7
9
  SocketRegistry: require('./SocketRegistry'),
8
- SOCKET_OPTIONS
9
- }
10
+ RoomRegistry: require('./RoomRegistry'),
11
+ SessionRegistry: require('./SessionRegistry'),
12
+ SOCKET_OPTIONS,
13
+ ERROR_CODES
14
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "errorCodes": [
4
+ "INVALID_MESSAGE",
5
+ "UNKNOWN_HANDLER",
6
+ "HANDLER_FAILED",
7
+ "BINARY_UNSUPPORTED",
8
+ "RATE_LIMITED",
9
+ "QUEUE_FULL",
10
+ "CAPACITY_REACHED",
11
+ "INITIALIZATION_FAILED"
12
+ ]
13
+ }
@@ -0,0 +1,21 @@
1
+ function validString(value, maxLength) {
2
+ return typeof value === 'string' && value.length > 0 && value.length <= maxLength;
3
+ }
4
+
5
+ function validateEnvelope(message, version) {
6
+ if (!message || typeof message !== 'object' || message.v !== version || !validString(message.type, 256)) return false;
7
+ if (message.requestId !== undefined && !validString(message.requestId, 256)) return false;
8
+ if (message.sequence !== undefined && (!Number.isSafeInteger(message.sequence) || message.sequence < 0)) return false;
9
+ if (message.type === 'error') {
10
+ return Boolean(
11
+ !Object.prototype.hasOwnProperty.call(message, 'payload') &&
12
+ message.error &&
13
+ typeof message.error === 'object' &&
14
+ validString(message.error.code, 256) &&
15
+ validString(message.error.message, 1024)
16
+ );
17
+ }
18
+ return Object.prototype.hasOwnProperty.call(message, 'payload') && message.error === undefined;
19
+ }
20
+
21
+ module.exports = { validateEnvelope };
@@ -1,33 +1,33 @@
1
- function closeWebSocketServer(server, clients, timeoutMs) {
2
- return new Promise((resolve, reject) => {
3
- let settled = false;
4
- const finish = (error) => {
5
- if (settled) return;
6
- settled = true;
7
- clearTimeout(timer);
8
- if (error) reject(error);
9
- else resolve();
10
- };
11
-
12
- const timer = setTimeout(() => {
13
- const errors = [];
14
- clients.forEach(socket => {
15
- try {
16
- socket.terminate?.();
17
- } catch (error) {
18
- errors.push(error);
19
- }
20
- });
21
- finish(errors[0]);
22
- }, timeoutMs);
23
- timer.unref();
24
-
25
- try {
26
- server.close(finish);
27
- } catch (error) {
28
- finish(error);
29
- }
30
- });
31
- }
32
-
33
- module.exports = { closeWebSocketServer };
1
+ function closeWebSocketServer(server, clients, timeoutMs) {
2
+ return new Promise((resolve, reject) => {
3
+ let settled = false;
4
+ const finish = (error) => {
5
+ if (settled) return;
6
+ settled = true;
7
+ clearTimeout(timer);
8
+ if (error) reject(error);
9
+ else resolve();
10
+ };
11
+
12
+ const timer = setTimeout(() => {
13
+ const errors = [];
14
+ clients.forEach(socket => {
15
+ try {
16
+ socket.terminate?.();
17
+ } catch (error) {
18
+ errors.push(error);
19
+ }
20
+ });
21
+ finish(errors[0]);
22
+ }, timeoutMs);
23
+ timer.unref();
24
+
25
+ try {
26
+ server.close(finish);
27
+ } catch (error) {
28
+ finish(error);
29
+ }
30
+ });
31
+ }
32
+
33
+ module.exports = { closeWebSocketServer };
package/src/ws/util.js CHANGED
@@ -1,30 +1,38 @@
1
- function canSend(socket) {
2
- return Boolean(
3
- socket &&
4
- typeof socket.send === 'function' &&
5
- (socket.readyState === undefined || socket.readyState === 1 || socket.readyState === socket.OPEN)
6
- );
7
- }
8
-
9
- function sendJson(socket, data) {
10
- if (!canSend(socket)) return false;
11
- socket.send(JSON.stringify(data));
12
- return true;
13
- }
14
-
15
- function broadcast(sockets, data) {
16
- const payload = JSON.stringify(data);
17
- let sent = 0;
18
- sockets.forEach((socket) => {
19
- if (!canSend(socket)) return;
20
- try {
21
- socket.send(payload);
22
- sent += 1;
23
- } catch {
24
- // A socket can close between the ready-state check and send.
25
- }
26
- });
27
- return sent;
28
- }
29
-
30
- module.exports = { sendJson, broadcast, canSend };
1
+ function canSend(socket) {
2
+ return Boolean(
3
+ socket &&
4
+ typeof socket.send === 'function' &&
5
+ (socket.readyState === undefined || socket.readyState === 1 || socket.readyState === socket.OPEN)
6
+ );
7
+ }
8
+
9
+ function sendPayload(socket, payload, policy) {
10
+ if (!canSend(socket)) return false;
11
+ if (policy && !policy.acceptsSend(socket, Buffer.byteLength(payload))) return false;
12
+ try {
13
+ socket.send(payload);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function sendJson(socket, data, policy) {
21
+ if (arguments.length < 3) {
22
+ if (!canSend(socket)) return false;
23
+ socket.send(JSON.stringify(data));
24
+ return true;
25
+ }
26
+ return sendPayload(socket, JSON.stringify(data), policy);
27
+ }
28
+
29
+ function broadcast(sockets, data, policy) {
30
+ const payload = JSON.stringify(data);
31
+ let sent = 0;
32
+ sockets.forEach((socket) => {
33
+ if (sendPayload(socket, payload, policy)) sent += 1;
34
+ });
35
+ return sent;
36
+ }
37
+
38
+ module.exports = { sendJson, sendPayload, broadcast, canSend };
@@ -1,73 +0,0 @@
1
- const fs = require('fs');
2
- const vm = require('vm');
3
- const path = require('path');
4
-
5
- class HtmxRenderer {
6
- /**
7
- * Render an .htmx file as JavaScript with embedded print statements.
8
- * @param {string} filePath - Path to the .htmx file.
9
- * @returns {string} Rendered HTML string with normalized whitespace.
10
- */
11
- static render(filePath, { rootDir = path.dirname(path.resolve(filePath)), timeoutMs = 1000 } = {}) {
12
- if (!fs.existsSync(filePath)) {
13
- throw new Error(`Template file not found: ${filePath}`);
14
- }
15
-
16
- let output = '';
17
- const templateContent = fs.readFileSync(filePath, 'utf-8');
18
-
19
- // Transform <@ ... @/> blocks into print() calls
20
- const transformedTemplate = templateContent.replace(
21
- /<@>([\s\S]*?)<@\/>/g,
22
- (_, content) => `print(\`${content.replace(/{{\s*(.*?)\s*}}/g, '${$1}')}\`);`
23
- );
24
-
25
- // Wrap the script in an IIFE
26
- const wrappedScript = `
27
- (() => {
28
- const print = (html) => output += html;
29
- ${transformedTemplate}
30
- return output;
31
- })();
32
- `;
33
-
34
- // Create a custom require function that resolves paths relative to the template
35
- const resolvedRoot = path.resolve(rootDir);
36
- const customRequire = (modulePath) => {
37
- if (typeof modulePath !== 'string' || !modulePath.startsWith('.')) {
38
- throw new Error('Templates may only require relative modules');
39
- }
40
- const absolutePath = path.resolve(path.dirname(filePath), modulePath);
41
- const relative = path.relative(resolvedRoot, absolutePath);
42
- if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
43
- throw new Error('Template module is outside the allowed root');
44
- }
45
- return require(absolutePath);
46
- };
47
-
48
- // Execute the script in a sandbox
49
- const script = new vm.Script(wrappedScript);
50
- const sandbox = {
51
- output: '',
52
- require: customRequire, // Add custom require
53
- __dirname: path.dirname(filePath),
54
- __filename: filePath,
55
- };
56
- vm.createContext(sandbox);
57
-
58
- // Get the rendered output
59
- let result = script.runInContext(sandbox, { timeout: timeoutMs });
60
-
61
- // Normalize spaces but preserve those in content
62
- result = result
63
- .replace(/>\s+</g, '><') // Remove spaces between tags
64
- .replace(/\s+/g, ' ') // Collapse multiple spaces to one
65
- .replace(/>\s+/g, '>') // Remove spaces after tags
66
- .replace(/\s+</g, '<') // Remove spaces before tags
67
- .trim(); // Trim leading and trailing spaces
68
-
69
- return result;
70
- }
71
- }
72
-
73
- module.exports = HtmxRenderer;
@@ -1,11 +0,0 @@
1
- class RedWebHtmxComponent {
2
- constructor(props = {}) {
3
- this.props = props;
4
- }
5
-
6
- render() {
7
- throw new Error('Render method must be implemented in derived components');
8
- }
9
- }
10
-
11
- module.exports = RedWebHtmxComponent;