redweb 0.14.0 → 0.16.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 (40) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +289 -276
  3. package/contract.d.ts +11 -3
  4. package/docs/CLI.md +1 -1
  5. package/docs/CLIENT_DEVELOPMENT.md +9 -5
  6. package/docs/CONNECTED_CLIENTS_VERIFICATION.md +65 -0
  7. package/docs/DEVELOPMENT.md +1 -1
  8. package/docs/GETTING_STARTED.md +1 -1
  9. package/docs/LIVE_HTML.md +2 -2
  10. package/docs/MIGRATION.md +1 -1
  11. package/docs/RELEASE_TRUST.md +29 -6
  12. package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
  13. package/docs/SOCKET_CONTRACTS.md +6 -3
  14. package/docs/SOCKET_PAGES.md +172 -0
  15. package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -0
  16. package/docs/SOCKET_PAGE_VERIFICATION.md +85 -0
  17. package/docs/generated.json +2217 -2208
  18. package/docs/releases/0.15.0.json +2217 -0
  19. package/docs/releases/0.16.0.json +2217 -0
  20. package/docs/topics.json +2 -1
  21. package/index.d.ts +57 -6
  22. package/index.js +7 -2
  23. package/package.json +6 -3
  24. package/recipes/shared/README.md +7 -0
  25. package/src/cli/templates.js +3 -2
  26. package/src/htmx/Jsx.js +2 -2
  27. package/src/htmx/LiveHtmlServer.js +1 -1
  28. package/src/htmx/PageManager.js +10 -7
  29. package/src/htmx/PageSocketRoute.js +132 -0
  30. package/src/htmx/ReactiveRenderer.js +8 -2
  31. package/src/htmx/SocketAction.js +19 -0
  32. package/src/htmx/metadata.js +6 -2
  33. package/src/ws/BaseHandler.js +6 -4
  34. package/src/ws/ConnectedClients.js +207 -0
  35. package/src/ws/HandlerGuard.js +4 -0
  36. package/src/ws/RoomRegistry.js +4 -2
  37. package/src/ws/RouteRuntime.js +11 -7
  38. package/src/ws/SocketAction.js +16 -0
  39. package/src/ws/SocketContract.js +3 -2
  40. package/src/ws/SocketRoute.js +7 -5
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ const { AccessPolicy, AccessDenied } = require('../access/AccessPolicy');
4
+ const { guards } = require('./HandlerGuard');
5
+ const { BoundedOperation } = require('../async/BoundedOperation');
6
+
7
+ /** Deliberately public rejection text; unexpected exceptions remain private. */
8
+ class ClientError extends Error {
9
+ constructor(message) {
10
+ if (typeof message !== 'string' || !message || message.length > 512) throw new TypeError('Client error text must contain 1–512 characters.');
11
+ super(message);
12
+ }
13
+ }
14
+
15
+ class ConnectedClient {
16
+ #owner;
17
+ constructor(owner, socket) {
18
+ this.#owner = owner;
19
+ Object.defineProperty(this, 'socket', { value: socket });
20
+ Object.freeze(this);
21
+ }
22
+ get identity() { this.#owner.assertActive(this.socket); return this.socket.context.principal; }
23
+ get rooms() { return this.#owner.rooms.roomsFor(this.socket); }
24
+ get room() {
25
+ const rooms = this.rooms;
26
+ if (rooms.length !== 1) throw new ClientError('Join a room first.');
27
+ return rooms[0];
28
+ }
29
+ get page() { this.#owner.assertActive(this.socket); return this.socket.page(this.#owner.options.page()); }
30
+ join(room, commit) { return this.#owner.join(this, room, commit); }
31
+ leave(room) { this.#owner.assertActive(this.socket); return this.#owner.rooms.leave(room, this.socket); }
32
+ }
33
+
34
+ /** One route-owned projection layer; RoomRegistry alone owns membership. */
35
+ class ConnectedClients {
36
+ #route;
37
+ #clients = new WeakMap();
38
+ #joining = new WeakSet();
39
+ #queued = new Set();
40
+ #generations = new Map();
41
+
42
+ constructor(options) {
43
+ if (!options || typeof options !== 'object') throw new TypeError('Connected clients require options.');
44
+ for (const name of ['identity', 'page', 'project']) {
45
+ if (typeof options[name] !== 'function') throw new TypeError(`Connected clients require ${name}.`);
46
+ }
47
+ for (const name of ['reject', 'errorState']) {
48
+ if (options[name] !== undefined && typeof options[name] !== 'function') throw new TypeError(`${name} must be a function.`);
49
+ }
50
+ if (options.raw !== undefined && (!options.raw || typeof options.raw.update !== 'function' ||
51
+ (options.raw.reject !== undefined && typeof options.raw.reject !== 'function'))) {
52
+ throw new TypeError('A raw adapter requires update and an optional reject callback.');
53
+ }
54
+ this.options = Object.freeze({ ...options, raw: options.raw && Object.freeze({ ...options.raw }) });
55
+ this.policy = new AccessPolicy(async context => {
56
+ const identity = await this.options.identity(context);
57
+ return identity !== undefined && identity !== null && identity !== false && Object.is(identity, context.principal);
58
+ }, options.authorizationTimeoutMs);
59
+ this.projection = new BoundedOperation(options.projectionTimeoutMs);
60
+ }
61
+
62
+ attach(route, rooms) {
63
+ if (this.#route) throw new TypeError('Connected clients belong to one route instance. Create them inside your application factory.');
64
+ if (!rooms || !route.protocolPolicy) throw new TypeError('Connected clients require rooms and a socket protocol.');
65
+ this.#route = route;
66
+ this.rooms = rooms;
67
+ }
68
+
69
+ assertActive(socket) {
70
+ if (!this.#route || this.#route.draining || this.#route.clients.get(socket.clientKey) !== socket ||
71
+ socket.readyState !== 1 || socket.context.signal.aborted) throw new AccessDenied('ACCESS_CANCELLED');
72
+ }
73
+
74
+ get(socket) {
75
+ this.assertActive(socket);
76
+ let client = this.#clients.get(socket);
77
+ if (!client) { client = new ConnectedClient(this, socket); this.#clients.set(socket, client); }
78
+ return client;
79
+ }
80
+
81
+ async check(socket) {
82
+ this.assertActive(socket);
83
+ await this.policy.check(socket.context);
84
+ const guard = guards.get(socket);
85
+ if (guard) await guard();
86
+ this.assertActive(socket);
87
+ if (!socket.page && !this.options.raw) throw new AccessDenied();
88
+ }
89
+
90
+ async join(client, room, commit = () => {}) {
91
+ const socket = client.socket;
92
+ if (typeof commit !== 'function') throw new TypeError('Room commit must be synchronous.');
93
+ if (this.#joining.has(socket)) throw new ClientError('A room join is already pending.');
94
+ const existing = this.rooms.has(room, socket);
95
+ this.#joining.add(socket);
96
+ try {
97
+ await this.check(socket);
98
+ if (!await this.rooms.enter(room, socket)) throw new ClientError('Room capacity or access denied.');
99
+ await this.check(socket);
100
+ if (!this.rooms.has(room, socket)) throw new AccessDenied('ACCESS_CANCELLED');
101
+ const result = commit();
102
+ if (result && typeof result.then === 'function') {
103
+ Promise.resolve(result).catch(() => {});
104
+ throw new TypeError('Room commit must be synchronous.');
105
+ }
106
+ } catch (error) {
107
+ if (!existing) this.rooms.leave(room, socket);
108
+ throw error;
109
+ } finally { this.#joining.delete(socket); this.changed(room); }
110
+ }
111
+
112
+ changed(room) {
113
+ if (this.#route.draining) return;
114
+ this.#generations.set(room, Symbol());
115
+ if (this.#queued.has(room)) return;
116
+ this.#queued.add(room);
117
+ queueMicrotask(() => {
118
+ if (!this.#queued.delete(room)) return;
119
+ void this.refresh(room).catch(error => this.#route.logger.error('Connected client refresh failed:', error));
120
+ });
121
+ }
122
+
123
+ refresh(room) {
124
+ this.rooms.validateRoomId(room);
125
+ this.#queued.delete(room);
126
+ return this.#route.runtime.run(() => this.project(room));
127
+ }
128
+
129
+ async project(room) {
130
+ const generation = Symbol();
131
+ this.#generations.set(room, generation);
132
+ const current = socket => this.#generations.get(room) === generation && this.rooms.has(room, socket) && !this.#joining.has(socket);
133
+ const eligible = [];
134
+ try {
135
+ await Promise.all(this.rooms.members(room).map(async socket => {
136
+ if (!current(socket)) return;
137
+ try {
138
+ await this.check(socket);
139
+ if (current(socket)) { const client = this.get(socket); eligible.push({ client, identity: client.identity }); }
140
+ }
141
+ catch (error) { if (current(socket)) this.exclude(socket, error); }
142
+ }));
143
+ const online = Object.freeze([...new Set(eligible.map(entry => entry.identity))]);
144
+ await Promise.all(eligible.map(async ({ client }) => {
145
+ const socket = client.socket;
146
+ try {
147
+ if (!current(socket)) return;
148
+ const { state, frame } = await this.projection.run(async () => {
149
+ const state = await this.options.project(client, room, online);
150
+ const frame = socket.page ? null : await this.options.raw.update(state);
151
+ return { state, frame };
152
+ }, socket.context.signal);
153
+ await this.check(socket);
154
+ if (!current(socket)) return;
155
+ if (socket.page) Object.assign(client.page, state);
156
+ else socket.sendEvent(frame.type, frame.payload);
157
+ } catch (error) { if (current(socket)) this.exclude(socket, error); }
158
+ }));
159
+ } finally {
160
+ if (this.#generations.get(room) === generation) this.#generations.delete(room);
161
+ }
162
+ }
163
+
164
+ exclude(socket, error) {
165
+ this.rooms.leaveAll(socket);
166
+ if (!(error instanceof AccessDenied)) this.#route.handleError(socket, error);
167
+ socket.close(error instanceof AccessDenied ? 1008 : 1011, 'Connection unavailable.');
168
+ }
169
+
170
+ bind(contract) {
171
+ return Object.freeze({
172
+ protocol: contract.protocol,
173
+ handler: (type, callback) => {
174
+ if (typeof callback !== 'function') throw new TypeError('A client handler requires a callback.');
175
+ return contract.handler(type, async (socket, payload, message) => {
176
+ const client = this.get(socket);
177
+ await this.check(socket);
178
+ try {
179
+ const result = await callback(client, payload);
180
+ if (result === false) return false;
181
+ await Promise.all(client.rooms.map(room => this.refresh(room)));
182
+ if (!socket.page && message.requestId !== undefined) {
183
+ await this.check(socket);
184
+ socket.sendEvent('redweb:result', null, { requestId: message.requestId });
185
+ }
186
+ return result;
187
+ } catch (error) {
188
+ const text = error instanceof ClientError ? error.message : this.options.reject?.(error);
189
+ if (typeof text !== 'string' || !text || text.length > 512) throw error;
190
+ const metadata = { requestId: message.requestId };
191
+ const frame = socket.page || !this.options.raw.reject ? null :
192
+ await this.projection.run(() => this.options.raw.reject(text), socket.context.signal);
193
+ await this.check(socket);
194
+ if (socket.page) {
195
+ if (this.options.errorState) Object.assign(client.page, this.options.errorState(text));
196
+ } else if (frame) socket.sendEvent(frame.type, frame.payload);
197
+ socket.sendProtocolError('COMMAND_REJECTED', text, metadata);
198
+ return false;
199
+ }
200
+ });
201
+ },
202
+ });
203
+ }
204
+ }
205
+
206
+ const connectedClients = options => new ConnectedClients(options);
207
+ module.exports = { connectedClients, ConnectedClients, ConnectedClient, ClientError };
@@ -0,0 +1,4 @@
1
+ 'use strict';
2
+ // A private hook, never a field supplied by an incoming message.
3
+ const guards = new WeakMap();
4
+ module.exports = { guards };
@@ -102,10 +102,12 @@ class RoomRegistry {
102
102
  return roomIds.length;
103
103
  }
104
104
 
105
- members(roomId) {
105
+ members(roomId) {
106
106
  this.validateRoomId(roomId);
107
107
  return [...(this.rooms.get(roomId) || [])];
108
- }
108
+ }
109
+
110
+ roomsFor(socket) { return [...(this.memberships.get(socket) || [])]; }
109
111
 
110
112
  has(roomId, socket) {
111
113
  this.validateRoomId(roomId);
@@ -5,7 +5,8 @@ const HeartbeatMonitor = require('./HeartbeatMonitor');
5
5
  const RoomRegistry = require('./RoomRegistry');
6
6
  const SessionRegistry = require('./SessionRegistry');
7
7
  const DistributionBridge = require('./DistributionBridge');
8
- const requestSnapshot = require('../context/RequestSnapshot');
8
+ const requestSnapshot = require('../context/RequestSnapshot');
9
+ const { ConnectedClients } = require('./ConnectedClients');
9
10
 
10
11
  function joinRoom(roomId) { return this.__redwebRuntimeOwner.rooms.join(roomId, this); }
11
12
  function enterRoom(roomId) { return this.__redwebRuntimeOwner.rooms.enter(roomId, this); }
@@ -23,7 +24,7 @@ function publishEvent(type, payload) { return this.__redwebRuntimeOwner.route.pu
23
24
  function connectionContext() { return this.__redwebRuntimeOwner.ensureContext(this); }
24
25
 
25
26
  class RouteRuntime {
26
- constructor(route, { heartbeat, rooms, sessions, distribution, drainHandlers }) {
27
+ constructor(route, { heartbeat, rooms, sessions, distribution, drainHandlers, connections }) {
27
28
  this.route = route;
28
29
  this.inFlight = drainHandlers ? new Set() : null;
29
30
  this.abortController = drainHandlers ? new AbortController() : null;
@@ -31,7 +32,8 @@ class RouteRuntime {
31
32
  this.contexts = new WeakMap();
32
33
  this.requests = new WeakMap();
33
34
  this.needsContext = Boolean(route.admissionPolicy || route.protocolPolicy || rooms || sessions || drainHandlers);
34
- try {
35
+ try {
36
+ if (connections !== undefined && !(connections instanceof ConnectedClients)) throw new TypeError('connections must be created with connectedClients().');
35
37
  this.heartbeat = heartbeat === undefined ? null : new HeartbeatMonitor(heartbeat, route.logger);
36
38
  this.rooms = rooms === undefined || rooms === false
37
39
  ? null
@@ -40,9 +42,10 @@ class RouteRuntime {
40
42
  socket.readyState === 1 && this.contexts.get(socket)?.active !== false,
41
43
  contextFor: socket => this.ensureContext(socket),
42
44
  policy: route.transportPolicy,
43
- onChange: action => {
45
+ onChange: (action, roomId) => {
44
46
  route.metrics?.increment(`redweb.room.${action}`);
45
- route.metrics?.gauge('redweb.rooms.active', this.rooms.size);
47
+ route.metrics?.gauge('redweb.rooms.active', this.rooms.size);
48
+ connections?.changed(roomId);
46
49
  },
47
50
  });
48
51
  this.sessions = sessions === undefined || sessions === false
@@ -51,9 +54,10 @@ class RouteRuntime {
51
54
  if (distribution !== undefined && distribution !== false && typeof distribution?.onEvent !== 'function') {
52
55
  throw new TypeError('`distribution.onEvent` must be a function.');
53
56
  }
54
- this.distribution = distribution === undefined || distribution === false
57
+ this.distribution = distribution === undefined || distribution === false
55
58
  ? null
56
- : new DistributionBridge(distribution, event => distribution.onEvent(event, route), route.logger);
59
+ : new DistributionBridge(distribution, event => distribution.onEvent(event, route), route.logger);
60
+ connections?.attach(route, this.rooms);
57
61
  } catch (error) {
58
62
  this.heartbeat?.stop();
59
63
  this.sessions?.stop();
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ // Browser-safe metadata keeps the standalone contract entry free of Node rendering dependencies.
4
+ const bindings = new WeakMap();
5
+ function register(Handler, type) {
6
+ bindings.set(Handler, { Handler, type });
7
+ Object.defineProperty(Handler, 'with', { value(payload) {
8
+ const serialized = JSON.stringify(payload);
9
+ if (serialized === undefined || serialized.length > 65536) throw new TypeError('Socket action payload must be bounded JSON.');
10
+ const binding = Object.freeze({});
11
+ bindings.set(binding, { Handler, type, payload: JSON.parse(serialized) });
12
+ return binding;
13
+ } });
14
+ return Handler;
15
+ }
16
+ module.exports = { register, bindings };
@@ -83,7 +83,7 @@ class SocketContract {
83
83
  if (!this.#validators.has(type)) throw new TypeError('Handler message type is not defined in the contract.');
84
84
  if (typeof callback !== 'function') throw new TypeError('A contract handler requires a callback.');
85
85
  const contract = this;
86
- return class ContractHandler extends BaseHandler {
86
+ class ContractHandler extends BaseHandler {
87
87
  constructor() { super(type); }
88
88
  async handleMessage(socket, message) {
89
89
  if (socket.context?.protocol?.version !== contract.version) throw new TypeError('The route must negotiate this contract version before handling messages.');
@@ -93,7 +93,8 @@ class SocketContract {
93
93
  return super.handleMessage(socket, { ...message, payload });
94
94
  }
95
95
  onMessage(socket, message) { return callback(socket, message.payload, message); }
96
- };
96
+ }
97
+ return require('./SocketAction').register(ContractHandler, type);
97
98
  }
98
99
 
99
100
  client(socket) { return new ContractClient(this, socket); }
@@ -93,11 +93,12 @@ class SocketRoute {
93
93
  limits,
94
94
  orderedMessages = false,
95
95
  heartbeat,
96
- rooms,
96
+ connections,
97
+ rooms = connections ? true : undefined,
97
98
  sessions,
98
99
  metrics,
99
100
  distribution,
100
- drainHandlers = false,
101
+ drainHandlers = Boolean(connections),
101
102
  protocol,
102
103
  maxPendingUpgrades = 64,
103
104
  } = {}) {
@@ -192,7 +193,7 @@ class SocketRoute {
192
193
  throw error;
193
194
  }
194
195
  try {
195
- this.runtime = new RouteRuntime(this, { heartbeat, rooms, sessions, distribution, drainHandlers });
196
+ this.runtime = new RouteRuntime(this, { heartbeat, rooms, sessions, distribution, drainHandlers, connections });
196
197
  Object.assign(this, this.runtime.expose());
197
198
  } catch (error) {
198
199
  this.disposeServices();
@@ -452,8 +453,9 @@ class SocketRoute {
452
453
  return false;
453
454
  } else {
454
455
  try {
455
- await handler.handleMessage(sock, data);
456
- return true;
456
+ // A handler may send a recoverable protocol error and explicitly decline
457
+ // success. Undefined remains successful for existing command handlers.
458
+ return await handler.handleMessage(sock, data) !== false;
457
459
  } catch (error) {
458
460
  if (this.sendAccessFailure(sock, error, { requestId: data.requestId })) return false;
459
461
  if (error instanceof InboundContractValidationError) {