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
@@ -0,0 +1,117 @@
1
+ const { broadcast } = require('./util');
2
+
3
+ class RoomRegistry {
4
+ constructor(options = {}, { hasConnection, policy, onChange } = {}) {
5
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
6
+ throw new TypeError('`rooms` must be an object or true.');
7
+ }
8
+ const {
9
+ maxRooms = 1000,
10
+ maxMembersPerRoom = 1000,
11
+ maxRoomsPerConnection = 32,
12
+ maxRoomIdLength = 128,
13
+ } = options;
14
+ for (const [name, value] of Object.entries({ maxRooms, maxMembersPerRoom, maxRoomsPerConnection, maxRoomIdLength })) {
15
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`\`rooms.${name}\` must be a positive integer.`);
16
+ }
17
+ if (hasConnection !== undefined && typeof hasConnection !== 'function') {
18
+ throw new TypeError('`hasConnection` must be a function.');
19
+ }
20
+ if (onChange !== undefined && typeof onChange !== 'function') {
21
+ throw new TypeError('`onChange` must be a function.');
22
+ }
23
+ this.options = { maxRooms, maxMembersPerRoom, maxRoomsPerConnection, maxRoomIdLength };
24
+ this.hasConnection = hasConnection || (() => true);
25
+ this.policy = policy;
26
+ this.onChange = onChange || (() => {});
27
+ this.rooms = new Map();
28
+ this.memberships = new WeakMap();
29
+ this.closed = false;
30
+ }
31
+
32
+ validateRoomId(roomId) {
33
+ if (typeof roomId !== 'string' || !roomId || roomId.length > this.options.maxRoomIdLength) {
34
+ throw new TypeError(`Room IDs must be non-empty strings of at most ${this.options.maxRoomIdLength} characters.`);
35
+ }
36
+ }
37
+
38
+ join(roomId, socket) {
39
+ this.validateRoomId(roomId);
40
+ if (this.closed) return false;
41
+ if (!socket || !this.hasConnection(socket)) return false;
42
+ let members = this.rooms.get(roomId);
43
+ if (members?.has(socket)) return true;
44
+ const memberships = this.memberships.get(socket) || new Set();
45
+ if (!members && this.rooms.size >= this.options.maxRooms) return false;
46
+ if (members && members.size >= this.options.maxMembersPerRoom) return false;
47
+ if (memberships.size >= this.options.maxRoomsPerConnection) return false;
48
+ if (!members) {
49
+ members = new Set();
50
+ this.rooms.set(roomId, members);
51
+ }
52
+ members.add(socket);
53
+ memberships.add(roomId);
54
+ this.memberships.set(socket, memberships);
55
+ this.onChange('join', roomId, socket);
56
+ return true;
57
+ }
58
+
59
+ leave(roomId, socket) {
60
+ this.validateRoomId(roomId);
61
+ const members = this.rooms.get(roomId);
62
+ if (!members?.delete(socket)) return false;
63
+ const memberships = this.memberships.get(socket);
64
+ memberships?.delete(roomId);
65
+ if (!memberships?.size) this.memberships.delete(socket);
66
+ if (!members.size) this.rooms.delete(roomId);
67
+ this.onChange('leave', roomId, socket);
68
+ return true;
69
+ }
70
+
71
+ leaveAll(socket) {
72
+ const memberships = this.memberships.get(socket);
73
+ if (!memberships) return 0;
74
+ const roomIds = [...memberships];
75
+ roomIds.forEach(roomId => this.leave(roomId, socket));
76
+ return roomIds.length;
77
+ }
78
+
79
+ members(roomId) {
80
+ this.validateRoomId(roomId);
81
+ return [...(this.rooms.get(roomId) || [])];
82
+ }
83
+
84
+ has(roomId, socket) {
85
+ this.validateRoomId(roomId);
86
+ return Boolean(this.rooms.get(roomId)?.has(socket));
87
+ }
88
+
89
+ broadcast(roomId, data, { except } = {}) {
90
+ this.validateRoomId(roomId);
91
+ if (this.closed) return 0;
92
+ const members = this.rooms.get(roomId);
93
+ if (!members) return 0;
94
+ const recipients = except === undefined
95
+ ? members
96
+ : [...members].filter(socket => socket !== except);
97
+ return broadcast(recipients, data, this.policy);
98
+ }
99
+
100
+ clear() {
101
+ this.rooms.clear();
102
+ this.memberships = new WeakMap();
103
+ }
104
+
105
+ close() {
106
+ if (this.closed) return false;
107
+ this.closed = true;
108
+ this.clear();
109
+ return true;
110
+ }
111
+
112
+ get size() {
113
+ return this.rooms.size;
114
+ }
115
+ }
116
+
117
+ module.exports = RoomRegistry;
@@ -0,0 +1,146 @@
1
+ const { randomUUID } = require('crypto');
2
+ const { ADMISSION_CONTEXT } = require('./AdmissionPolicy');
3
+ const { PROTOCOL_CONTEXT } = require('./ProtocolPolicy');
4
+ const HeartbeatMonitor = require('./HeartbeatMonitor');
5
+ const RoomRegistry = require('./RoomRegistry');
6
+ const SessionRegistry = require('./SessionRegistry');
7
+ const DistributionBridge = require('./DistributionBridge');
8
+
9
+ function joinRoom(roomId) { return this.__redwebRuntimeOwner.rooms.join(roomId, this); }
10
+ function leaveRoom(roomId) { return this.__redwebRuntimeOwner.rooms.leave(roomId, this); }
11
+ function roomBroadcast(roomId, data, options) { return this.__redwebRuntimeOwner.rooms.broadcast(roomId, data, options); }
12
+ function createSession(sessionId, data) {
13
+ this.__redwebRuntimeOwner.ensureContext(this);
14
+ return this.__redwebRuntimeOwner.sessions.create(sessionId, data, this);
15
+ }
16
+ function resumeSession(sessionId) {
17
+ this.__redwebRuntimeOwner.ensureContext(this);
18
+ return this.__redwebRuntimeOwner.sessions.resume(sessionId, this);
19
+ }
20
+ function publishEvent(type, payload) { return this.__redwebRuntimeOwner.route.publish(type, payload); }
21
+
22
+ class RouteRuntime {
23
+ constructor(route, { heartbeat, rooms, sessions, distribution, drainHandlers }) {
24
+ this.route = route;
25
+ this.inFlight = drainHandlers ? new Set() : null;
26
+ this.abortController = drainHandlers ? new AbortController() : null;
27
+ this.acceptingWork = true;
28
+ try {
29
+ this.heartbeat = heartbeat === undefined ? null : new HeartbeatMonitor(heartbeat, route.logger);
30
+ this.rooms = rooms === undefined || rooms === false
31
+ ? null
32
+ : new RoomRegistry(rooms === true ? {} : rooms, {
33
+ hasConnection: socket => route.clients.get(socket.clientKey) === socket,
34
+ policy: route.transportPolicy,
35
+ onChange: action => {
36
+ route.metrics?.increment(`redweb.room.${action}`);
37
+ route.metrics?.gauge('redweb.rooms.active', this.rooms.size);
38
+ },
39
+ });
40
+ this.sessions = sessions === undefined || sessions === false
41
+ ? null
42
+ : new SessionRegistry(sessions === true ? {} : sessions, route.logger);
43
+ if (distribution !== undefined && distribution !== false && typeof distribution?.onEvent !== 'function') {
44
+ throw new TypeError('`distribution.onEvent` must be a function.');
45
+ }
46
+ this.distribution = distribution === undefined || distribution === false
47
+ ? null
48
+ : new DistributionBridge(distribution, event => distribution.onEvent(event, route), route.logger);
49
+ } catch (error) {
50
+ this.heartbeat?.stop();
51
+ this.sessions?.stop();
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ expose() {
57
+ return {
58
+ heartbeatMonitor: this.heartbeat,
59
+ rooms: this.rooms,
60
+ sessions: this.sessions,
61
+ distribution: this.distribution,
62
+ inFlight: this.inFlight,
63
+ abortController: this.abortController,
64
+ };
65
+ }
66
+
67
+ decorate(socket, request) {
68
+ if (request?.[ADMISSION_CONTEXT] || request?.[PROTOCOL_CONTEXT] || this.abortController) this.ensureContext(socket, request);
69
+ if (this.rooms || this.sessions || this.distribution) socket.__redwebRuntimeOwner = this;
70
+ if (this.rooms) {
71
+ socket.joinRoom = joinRoom;
72
+ socket.leaveRoom = leaveRoom;
73
+ socket.roomBroadcast = roomBroadcast;
74
+ }
75
+ if (this.sessions) {
76
+ socket.createSession = createSession;
77
+ socket.resumeSession = resumeSession;
78
+ }
79
+ if (this.distribution) socket.publishEvent = publishEvent;
80
+ }
81
+
82
+ ensureContext(socket, request) {
83
+ if (socket.context) return socket.context;
84
+ socket.context = {
85
+ connectionId: randomUUID(),
86
+ principal: request?.[ADMISSION_CONTEXT]?.principal,
87
+ session: null,
88
+ metadata: Object.create(null),
89
+ signal: this.abortController?.signal,
90
+ protocol: request?.[PROTOCOL_CONTEXT],
91
+ };
92
+ return socket.context;
93
+ }
94
+
95
+ attach(socket) {
96
+ this.heartbeat?.attach(socket);
97
+ }
98
+
99
+ detach(socket) {
100
+ this.rooms?.leaveAll(socket);
101
+ this.sessions?.release(socket);
102
+ this.heartbeat?.detach(socket);
103
+ }
104
+
105
+ run(task) {
106
+ if (!this.acceptingWork) return Promise.resolve(false);
107
+ if (!this.inFlight) return task();
108
+ const promise = Promise.resolve().then(task);
109
+ this.inFlight.add(promise);
110
+ const cleanup = () => this.inFlight.delete(promise);
111
+ void promise.then(cleanup, cleanup);
112
+ return promise;
113
+ }
114
+
115
+ beginDrain() {
116
+ this.abortController?.abort();
117
+ this.route.clients.forEach(socket => socket.__redwebRuntime?.queue?.close());
118
+ }
119
+
120
+ isReady() {
121
+ return !this.distribution?.required || this.distribution.isReady();
122
+ }
123
+
124
+ stopHeartbeat() {
125
+ this.heartbeat?.stop();
126
+ }
127
+
128
+ closeDistribution() {
129
+ return this.distribution?.close();
130
+ }
131
+
132
+ closeState() {
133
+ this.rooms?.close();
134
+ this.sessions?.stop();
135
+ }
136
+
137
+ clearInFlight() {
138
+ this.inFlight?.clear();
139
+ }
140
+
141
+ stopAcceptingWork() {
142
+ this.acceptingWork = false;
143
+ }
144
+ }
145
+
146
+ module.exports = RouteRuntime;
@@ -7,14 +7,14 @@ const { BaseSocketServer } = require('./BaseSocketServer');
7
7
  * @param {SocketServerOptions} options - Configuration options for SecureSocketServer.
8
8
  * @return {Object} WebSocket server instance.
9
9
  */
10
- class SecureSocketServer extends BaseSocketServer {
11
- constructor(options) {
12
- const ownsServer = !options?.server;
13
- const sslOptions = ownsServer ? loadSslConfig(options?.ssl) : null;
14
- const server = options?.server || https.createServer(sslOptions);
15
- super(server, options, ownsServer, 'SecureSocketServer');
16
- return this;
17
- }
18
- }
10
+ class SecureSocketServer extends BaseSocketServer {
11
+ constructor(options) {
12
+ const ownsServer = !options?.server;
13
+ const sslOptions = ownsServer ? loadSslConfig(options?.ssl) : null;
14
+ const server = options?.server || https.createServer(sslOptions);
15
+ super(server, options, ownsServer, 'SecureSocketServer');
16
+ return this;
17
+ }
18
+ }
19
19
 
20
20
  module.exports = SecureSocketServer;
@@ -0,0 +1,135 @@
1
+ const { performance } = require('perf_hooks');
2
+
3
+ class SessionRegistry {
4
+ constructor(options = {}, logger = console, clock = () => performance.now()) {
5
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
6
+ throw new TypeError('`sessions` must be an object or true.');
7
+ }
8
+ const {
9
+ ttlMs = 30_000,
10
+ maxSessions = 10_000,
11
+ maxSessionIdLength = 256,
12
+ sweepIntervalMs = Math.min(ttlMs, 1000),
13
+ } = options;
14
+ for (const [name, value] of Object.entries({ ttlMs, maxSessions, maxSessionIdLength, sweepIntervalMs })) {
15
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`\`sessions.${name}\` must be a positive integer.`);
16
+ }
17
+ if (typeof clock !== 'function') throw new TypeError('`clock` must be a function.');
18
+ this.ttlMs = ttlMs;
19
+ this.maxSessions = maxSessions;
20
+ this.maxSessionIdLength = maxSessionIdLength;
21
+ this.logger = logger;
22
+ this.clock = clock;
23
+ this.sessions = new Map();
24
+ this.closed = false;
25
+ this.timer = setInterval(() => this.sweep(), sweepIntervalMs);
26
+ this.timer.unref();
27
+ }
28
+
29
+ validateId(sessionId) {
30
+ if (typeof sessionId !== 'string' || !sessionId || sessionId.length > this.maxSessionIdLength) {
31
+ throw new TypeError(`Session IDs must be non-empty strings of at most ${this.maxSessionIdLength} characters.`);
32
+ }
33
+ }
34
+
35
+ create(sessionId, data, socket) {
36
+ this.validateId(sessionId);
37
+ if (this.closed || this.sessions.has(sessionId)) return false;
38
+ if (this.sessions.size >= this.maxSessions) this.sweep();
39
+ if (this.sessions.size >= this.maxSessions) return false;
40
+ const record = { data, socket: null, expiresAt: this.clock() + this.ttlMs };
41
+ this.sessions.set(sessionId, record);
42
+ if (socket) this.assign(sessionId, record, socket);
43
+ return true;
44
+ }
45
+
46
+ resume(sessionId, socket) {
47
+ this.validateId(sessionId);
48
+ if (this.closed) return null;
49
+ const record = this.sessions.get(sessionId);
50
+ if (!record) return null;
51
+ if (!record.socket && record.expiresAt <= this.clock()) {
52
+ this.sessions.delete(sessionId);
53
+ return null;
54
+ }
55
+ this.assign(sessionId, record, socket);
56
+ return record.data;
57
+ }
58
+
59
+ assign(sessionId, record, socket) {
60
+ if (!socket) throw new TypeError('A socket is required to own a session.');
61
+ const previousSessionId = socket.__redwebSessionId;
62
+ if (previousSessionId && previousSessionId !== sessionId) this.release(socket);
63
+ const previousSocket = record.socket;
64
+ record.socket = socket;
65
+ record.expiresAt = Infinity;
66
+ socket.__redwebSessionId = sessionId;
67
+ if (socket.context) socket.context.session = { id: sessionId, data: record.data };
68
+ if (previousSocket && previousSocket !== socket) {
69
+ previousSocket.__redwebSessionId = undefined;
70
+ if (previousSocket.context) previousSocket.context.session = null;
71
+ try {
72
+ previousSocket.close?.(4000, 'Session resumed elsewhere');
73
+ } catch (error) {
74
+ this.logger?.error?.('Error closing replaced session socket:', error);
75
+ }
76
+ }
77
+ }
78
+
79
+ release(socket) {
80
+ const sessionId = socket?.__redwebSessionId;
81
+ if (!sessionId) return false;
82
+ const record = this.sessions.get(sessionId);
83
+ socket.__redwebSessionId = undefined;
84
+ if (socket.context) socket.context.session = null;
85
+ if (!record || record.socket !== socket) return false;
86
+ record.socket = null;
87
+ record.expiresAt = this.clock() + this.ttlMs;
88
+ return true;
89
+ }
90
+
91
+ remove(sessionId) {
92
+ this.validateId(sessionId);
93
+ if (this.closed) return false;
94
+ const record = this.sessions.get(sessionId);
95
+ if (!record) return false;
96
+ if (record.socket) {
97
+ record.socket.__redwebSessionId = undefined;
98
+ if (record.socket.context) record.socket.context.session = null;
99
+ }
100
+ return this.sessions.delete(sessionId);
101
+ }
102
+
103
+ get(sessionId) {
104
+ this.validateId(sessionId);
105
+ if (this.closed) return undefined;
106
+ return this.sessions.get(sessionId)?.data;
107
+ }
108
+
109
+ sweep() {
110
+ const now = this.clock();
111
+ this.sessions.forEach((record, sessionId) => {
112
+ if (!record.socket && record.expiresAt <= now) this.sessions.delete(sessionId);
113
+ });
114
+ }
115
+
116
+ stop() {
117
+ if (this.closed) return;
118
+ this.closed = true;
119
+ if (this.timer) clearInterval(this.timer);
120
+ this.timer = null;
121
+ this.sessions.forEach(record => {
122
+ if (record.socket) {
123
+ record.socket.__redwebSessionId = undefined;
124
+ if (record.socket.context) record.socket.context.session = null;
125
+ }
126
+ });
127
+ this.sessions.clear();
128
+ }
129
+
130
+ get size() {
131
+ return this.sessions.size;
132
+ }
133
+ }
134
+
135
+ module.exports = SessionRegistry;