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.
- package/CHANGELOG.md +16 -3
- package/README.md +289 -276
- package/contract.d.ts +11 -3
- package/docs/CLI.md +1 -1
- package/docs/CLIENT_DEVELOPMENT.md +9 -5
- package/docs/CONNECTED_CLIENTS_VERIFICATION.md +65 -0
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LIVE_HTML.md +2 -2
- package/docs/MIGRATION.md +1 -1
- package/docs/RELEASE_TRUST.md +29 -6
- package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
- package/docs/SOCKET_CONTRACTS.md +6 -3
- package/docs/SOCKET_PAGES.md +172 -0
- package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -0
- package/docs/SOCKET_PAGE_VERIFICATION.md +85 -0
- package/docs/generated.json +2217 -2208
- package/docs/releases/0.15.0.json +2217 -0
- package/docs/releases/0.16.0.json +2217 -0
- package/docs/topics.json +2 -1
- package/index.d.ts +57 -6
- package/index.js +7 -2
- package/package.json +6 -3
- package/recipes/shared/README.md +7 -0
- package/src/cli/templates.js +3 -2
- package/src/htmx/Jsx.js +2 -2
- package/src/htmx/LiveHtmlServer.js +1 -1
- package/src/htmx/PageManager.js +10 -7
- package/src/htmx/PageSocketRoute.js +132 -0
- package/src/htmx/ReactiveRenderer.js +8 -2
- package/src/htmx/SocketAction.js +19 -0
- package/src/htmx/metadata.js +6 -2
- package/src/ws/BaseHandler.js +6 -4
- package/src/ws/ConnectedClients.js +207 -0
- package/src/ws/HandlerGuard.js +4 -0
- package/src/ws/RoomRegistry.js +4 -2
- package/src/ws/RouteRuntime.js +11 -7
- package/src/ws/SocketAction.js +16 -0
- package/src/ws/SocketContract.js +3 -2
- 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 };
|
package/src/ws/RoomRegistry.js
CHANGED
|
@@ -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);
|
package/src/ws/RouteRuntime.js
CHANGED
|
@@ -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 };
|
package/src/ws/SocketContract.js
CHANGED
|
@@ -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
|
-
|
|
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); }
|
package/src/ws/SocketRoute.js
CHANGED
|
@@ -93,11 +93,12 @@ class SocketRoute {
|
|
|
93
93
|
limits,
|
|
94
94
|
orderedMessages = false,
|
|
95
95
|
heartbeat,
|
|
96
|
-
|
|
96
|
+
connections,
|
|
97
|
+
rooms = connections ? true : undefined,
|
|
97
98
|
sessions,
|
|
98
99
|
metrics,
|
|
99
100
|
distribution,
|
|
100
|
-
drainHandlers =
|
|
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
|
-
|
|
456
|
-
|
|
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) {
|