redweb 0.16.1 → 0.16.3
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 +35 -22
- package/README.md +293 -289
- package/contract.d.ts +11 -11
- package/docs/API_EXAMPLES_VERIFICATION.md +22 -22
- package/docs/APPLICATION.md +96 -94
- package/docs/CLI.md +122 -116
- package/docs/CLIENT_DEVELOPMENT.md +9 -9
- package/docs/CONNECTED_CLIENTS_VERIFICATION.md +65 -65
- package/docs/DEVELOPMENT.md +81 -81
- package/docs/GETTING_STARTED.md +78 -58
- package/docs/LIVE_HTML.md +555 -478
- package/docs/MIGRATION.md +28 -28
- package/docs/RELEASE_TRUST.md +88 -88
- package/docs/RUNTIME_DIAGNOSTICS.md +78 -78
- package/docs/SOCKET_CONTRACTS.md +42 -42
- package/docs/SOCKET_PAGES.md +172 -172
- package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -120
- package/docs/SOCKET_PAGE_VERIFICATION.md +85 -85
- package/docs/generated.json +2286 -2286
- package/docs/guides/chatroom.md +1 -1
- package/docs/guides/jsx-without-react.md +14 -14
- package/docs/reference.json +1329 -1329
- package/docs/releases/0.15.0.json +2217 -2217
- package/docs/releases/0.16.0.json +2217 -2217
- package/docs/releases/0.16.1.json +2286 -2286
- package/docs/releases/0.16.2.json +2286 -0
- package/docs/releases/0.16.3.json +2286 -0
- package/docs/snippets/components.tsx +24 -24
- package/docs/snippets/counter.tsx +16 -16
- package/docs/snippets/room-access.tsx +11 -11
- package/docs/snippets/site.css +2 -2
- package/docs/snippets/site.tsx +22 -22
- package/docs/topics.json +3 -3
- package/index.d.ts +92 -57
- package/index.js +13 -8
- package/package.json +8 -8
- package/recipes/foundation/README.md +7 -0
- package/recipes/foundation/app.test.cjs +15 -0
- package/recipes/foundation/app.tsx +12 -0
- package/recipes/shared/README.md +7 -7
- package/src/Application.js +4 -4
- package/src/access/failure-codes.json +4 -0
- package/src/cli/ProjectInitializer.js +1 -1
- package/src/cli/arguments.js +15 -3
- package/src/cli/run.js +10 -1
- package/src/cli/templates.js +34 -21
- package/src/docs/Documentation.js +25 -15
- package/src/htmx/Jsx.js +2 -2
- package/src/htmx/LiveHtmlServer.js +5 -1
- package/src/htmx/LivePage.js +5 -1
- package/src/htmx/LiveResource.js +96 -0
- package/src/htmx/PageManager.js +126 -13
- package/src/htmx/PageSocketRoute.js +132 -132
- package/src/htmx/PageTaskLane.js +39 -0
- package/src/htmx/ReactiveRenderer.js +8 -8
- package/src/htmx/SocketAction.js +19 -19
- package/src/htmx/TemplateRenderer.js +1 -1
- package/src/htmx/index.js +3 -2
- package/src/htmx/metadata.js +117 -6
- package/src/ws/BaseHandler.js +6 -6
- package/src/ws/ConnectedClients.js +207 -207
- package/src/ws/HandlerGuard.js +4 -4
- package/src/ws/RoomRegistry.js +4 -4
- package/src/ws/RouteRuntime.js +11 -11
- package/src/ws/SocketAction.js +16 -16
- package/src/ws/SocketContract.js +3 -3
- package/src/ws/SocketRoute.js +7 -7
package/src/htmx/index.js
CHANGED
|
@@ -2,9 +2,10 @@ const HtmlRenderer = require('./HtmlRenderer');
|
|
|
2
2
|
const LiveHtmlServer = require('./LiveHtmlServer');
|
|
3
3
|
const LivePage = require('./LivePage');
|
|
4
4
|
const { attribute, codeBlock, each, html, safeUrl: url } = require('./Html');
|
|
5
|
-
const { action, component, page, state, view } = require('./metadata');
|
|
5
|
+
const { action, component, inject, page, resource, state, upload, view } = require('./metadata');
|
|
6
|
+
const { LiveResource, liveResource } = require('./LiveResource');
|
|
6
7
|
const { start } = require('./start');
|
|
7
8
|
const { exportStatic } = require('./StaticExporter');
|
|
8
9
|
const { defineSite } = require('./StaticSite');
|
|
9
10
|
|
|
10
|
-
module.exports = { action, attribute, codeBlock, component, defineSite, each, exportStatic, html, HtmlRenderer, LiveHtmlServer, LivePage, page, start, state, url, view };
|
|
11
|
+
module.exports = { action, attribute, codeBlock, component, defineSite, each, exportStatic, html, HtmlRenderer, inject, LiveHtmlServer, LivePage, LiveResource, liveResource, page, resource, start, state, upload, url, view };
|
package/src/htmx/metadata.js
CHANGED
|
@@ -7,6 +7,12 @@ const STANDARD_ACTIONS = new WeakMap();
|
|
|
7
7
|
const VIEW_METADATA = new WeakMap();
|
|
8
8
|
const RESOLVED_VIEW = new WeakMap();
|
|
9
9
|
const STANDARD_VIEWS = new WeakMap();
|
|
10
|
+
const RESOURCE_METADATA = new WeakMap();
|
|
11
|
+
const RESOLVED_RESOURCE = new WeakMap();
|
|
12
|
+
const INJECT_METADATA = new WeakMap();
|
|
13
|
+
const RESOLVED_INJECT = new WeakMap();
|
|
14
|
+
const UPLOAD_METADATA = new WeakMap();
|
|
15
|
+
const RESOLVED_UPLOAD = new WeakMap();
|
|
10
16
|
const PAGE_ROOTS = new WeakMap();
|
|
11
17
|
const PAGE_STYLESHEET_ROOTS = new WeakMap();
|
|
12
18
|
const COMPONENT_CLASSES = new WeakSet();
|
|
@@ -65,6 +71,27 @@ function registerView(store, PageClass, stateName, method, implementation) {
|
|
|
65
71
|
metadataVersion += 1;
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
function registerResource(PageClass, property, config) {
|
|
75
|
+
const properties = new Map(RESOURCE_METADATA.get(PageClass) || []);
|
|
76
|
+
properties.set(property, config);
|
|
77
|
+
RESOURCE_METADATA.set(PageClass, properties);
|
|
78
|
+
metadataVersion += 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function registerInject(PageClass, property, provider) {
|
|
82
|
+
const properties = new Map(INJECT_METADATA.get(PageClass) || []);
|
|
83
|
+
properties.set(property, provider);
|
|
84
|
+
INJECT_METADATA.set(PageClass, properties);
|
|
85
|
+
metadataVersion += 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function registerUpload(PageClass, method, config) {
|
|
89
|
+
const methods = new Map(UPLOAD_METADATA.get(PageClass) || []);
|
|
90
|
+
methods.set(method, config);
|
|
91
|
+
UPLOAD_METADATA.set(PageClass, methods);
|
|
92
|
+
metadataVersion += 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
function resolvedState(PageClass) {
|
|
69
96
|
const cached = RESOLVED_STATE.get(PageClass);
|
|
70
97
|
if (cached?.version === metadataVersion) return cached.value;
|
|
@@ -112,6 +139,15 @@ function resolvedView(PageClass) {
|
|
|
112
139
|
return value;
|
|
113
140
|
}
|
|
114
141
|
|
|
142
|
+
function resolved(store, cache, PageClass) {
|
|
143
|
+
const cached = cache.get(PageClass);
|
|
144
|
+
if (cached?.version === metadataVersion) return cached.value;
|
|
145
|
+
const value = new Map();
|
|
146
|
+
hierarchy(PageClass).forEach(CurrentClass => store.get(CurrentClass)?.forEach((config, property) => value.set(property, config)));
|
|
147
|
+
cache.set(PageClass, { version: metadataVersion, value });
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
115
151
|
function pageHead(value) {
|
|
116
152
|
if (value === undefined) return undefined;
|
|
117
153
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Page head must be an object.');
|
|
@@ -178,10 +214,10 @@ function page(routePath, options = {}) {
|
|
|
178
214
|
if (!['connection', 'shared'].includes(scope)) {
|
|
179
215
|
throw new TypeError('Page scope must be "connection" or "shared".');
|
|
180
216
|
}
|
|
181
|
-
if (typeof live !== 'boolean') throw new TypeError('Page live must be a boolean.');
|
|
182
|
-
if (options.socket !== undefined && (typeof options.socket !== 'function' || !live || scope !== 'connection')) {
|
|
183
|
-
throw new TypeError('Page socket requires a route class and a live connection-scoped page.');
|
|
184
|
-
}
|
|
217
|
+
if (typeof live !== 'boolean') throw new TypeError('Page live must be a boolean.');
|
|
218
|
+
if (options.socket !== undefined && (typeof options.socket !== 'function' || !live || scope !== 'connection')) {
|
|
219
|
+
throw new TypeError('Page socket requires a route class and a live connection-scoped page.');
|
|
220
|
+
}
|
|
185
221
|
if (layout !== undefined && typeof layout !== 'function') throw new TypeError('Page layout must be a function.');
|
|
186
222
|
const head = pageHead(options.head);
|
|
187
223
|
const cache = pageCache(options.cache, live);
|
|
@@ -192,8 +228,8 @@ function page(routePath, options = {}) {
|
|
|
192
228
|
PAGE_METADATA.set(PageClass, Object.freeze({
|
|
193
229
|
path: routePath,
|
|
194
230
|
template,
|
|
195
|
-
scope,
|
|
196
|
-
...(options.socket && { socket: options.socket }),
|
|
231
|
+
scope,
|
|
232
|
+
...(options.socket && { socket: options.socket }),
|
|
197
233
|
...(live === false && { live: false }),
|
|
198
234
|
...(head && { head }),
|
|
199
235
|
...(cache && { cache }),
|
|
@@ -250,6 +286,71 @@ function state(options = {}) {
|
|
|
250
286
|
};
|
|
251
287
|
}
|
|
252
288
|
|
|
289
|
+
function resource(liveResource, select) {
|
|
290
|
+
const { LiveResource } = require('./LiveResource');
|
|
291
|
+
if (!(liveResource instanceof LiveResource)) throw new TypeError('resource() requires a value created by liveResource().');
|
|
292
|
+
if (typeof select !== 'function') throw new TypeError('resource() requires a key selector.');
|
|
293
|
+
const config = Object.freeze({ resource: liveResource, select });
|
|
294
|
+
return (target, property) => {
|
|
295
|
+
if (property?.kind === 'field') {
|
|
296
|
+
if (property.static || property.private || typeof property.name !== 'string' || !property.name) {
|
|
297
|
+
throw new TypeError('resource() requires a public instance field with a string name.');
|
|
298
|
+
}
|
|
299
|
+
property.addInitializer(function registerStandardResource() {
|
|
300
|
+
registerState(this.constructor, property.name, Object.freeze({ writable: false }));
|
|
301
|
+
registerResource(this.constructor, property.name, config);
|
|
302
|
+
});
|
|
303
|
+
return initialValue => initialValue;
|
|
304
|
+
}
|
|
305
|
+
const PageClass = assertDecoratorTarget(target, 'resource()');
|
|
306
|
+
if (typeof property !== 'string' || !property) throw new TypeError('Resource property must be a non-empty string.');
|
|
307
|
+
registerState(PageClass, property, Object.freeze({ writable: false }));
|
|
308
|
+
registerResource(PageClass, property, config);
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function inject(provider) {
|
|
313
|
+
if (typeof provider !== 'string' || !provider || provider.length > 128 || ['__proto__', 'prototype', 'constructor'].includes(provider)) {
|
|
314
|
+
throw new TypeError('inject() requires a safe non-empty provider name of at most 128 characters.');
|
|
315
|
+
}
|
|
316
|
+
return (target, property) => {
|
|
317
|
+
if (property?.kind === 'field') {
|
|
318
|
+
if (property.static || property.private || typeof property.name !== 'string' || !property.name) {
|
|
319
|
+
throw new TypeError('inject() requires a public instance field with a string name.');
|
|
320
|
+
}
|
|
321
|
+
property.addInitializer(function registerStandardInject() { registerInject(this.constructor, property.name, provider); });
|
|
322
|
+
return initialValue => initialValue;
|
|
323
|
+
}
|
|
324
|
+
const PageClass = assertDecoratorTarget(target, 'inject()');
|
|
325
|
+
if (typeof property !== 'string' || !property) throw new TypeError('Injected property must be a non-empty string.');
|
|
326
|
+
registerInject(PageClass, property, provider);
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function upload(options = {}) {
|
|
331
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('upload() options must be an object.');
|
|
332
|
+
const { maxBytes = 10 * 1024 * 1024, accept = [] } = options;
|
|
333
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 1024 * 1024 * 1024) throw new RangeError('upload() maxBytes must be an integer between 1 and 1 GiB.');
|
|
334
|
+
const types = Array.isArray(accept) ? accept : [accept];
|
|
335
|
+
if (types.some(type => typeof type !== 'string' || !/^[a-z]+\/(?:[a-z0-9.+-]+|\*)$/.test(type))) throw new TypeError('upload() accept must contain MIME types or type wildcards.');
|
|
336
|
+
const config = Object.freeze({ maxBytes, accept: Object.freeze([...new Set(types)]) });
|
|
337
|
+
return (target, method, descriptor) => {
|
|
338
|
+
if (method?.kind === 'method') {
|
|
339
|
+
if (method.static || method.private || typeof method.name !== 'string' || !method.name || typeof target !== 'function') throw new TypeError('upload() requires a public instance method with a string name.');
|
|
340
|
+
const entry = Object.freeze({ implementation: target, definition: new ActionDefinition() });
|
|
341
|
+
method.addInitializer(function registerStandardUpload() {
|
|
342
|
+
if (this[method.name] === target) { registerStandardAction(this.constructor, method.name, entry); registerUpload(this.constructor, method.name, config); }
|
|
343
|
+
});
|
|
344
|
+
return target;
|
|
345
|
+
}
|
|
346
|
+
const PageClass = assertDecoratorTarget(target, 'upload()');
|
|
347
|
+
if (typeof method !== 'string' || !method || typeof descriptor?.value !== 'function') throw new TypeError('upload() must decorate a method.');
|
|
348
|
+
registerAction(PageClass, method, Object.freeze({ implementation: descriptor.value, definition: new ActionDefinition() }));
|
|
349
|
+
registerUpload(PageClass, method, config);
|
|
350
|
+
return descriptor;
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
253
354
|
function action(options) {
|
|
254
355
|
const definition = new ActionDefinition(options);
|
|
255
356
|
return (target, method, descriptor) => {
|
|
@@ -321,6 +422,10 @@ function getStateConfig(PageClass, property) {
|
|
|
321
422
|
return resolvedState(PageClass).get(property);
|
|
322
423
|
}
|
|
323
424
|
|
|
425
|
+
function getResourceMetadata(PageClass) { return new Map(resolved(RESOURCE_METADATA, RESOLVED_RESOURCE, PageClass)); }
|
|
426
|
+
function getInjectMetadata(PageClass) { return new Map(resolved(INJECT_METADATA, RESOLVED_INJECT, PageClass)); }
|
|
427
|
+
function getUploadMetadata(PageClass) { return new Map(resolved(UPLOAD_METADATA, RESOLVED_UPLOAD, PageClass)); }
|
|
428
|
+
|
|
324
429
|
function forEachState(PageClass, callback) {
|
|
325
430
|
resolvedState(PageClass).forEach(callback);
|
|
326
431
|
}
|
|
@@ -349,6 +454,9 @@ module.exports = {
|
|
|
349
454
|
getActionDefinition,
|
|
350
455
|
getActionMetadata,
|
|
351
456
|
getPageMetadata,
|
|
457
|
+
getInjectMetadata,
|
|
458
|
+
getUploadMetadata,
|
|
459
|
+
getResourceMetadata,
|
|
352
460
|
getPageStylesheetRoots,
|
|
353
461
|
getPageTemplateRoot,
|
|
354
462
|
getStateConfig,
|
|
@@ -356,10 +464,13 @@ module.exports = {
|
|
|
356
464
|
getViewImplementation,
|
|
357
465
|
getViewMetadata,
|
|
358
466
|
isComponentClass,
|
|
467
|
+
inject,
|
|
359
468
|
page,
|
|
360
469
|
pageCache,
|
|
361
470
|
pageHead,
|
|
362
471
|
setPageStylesheetRoots,
|
|
363
472
|
state,
|
|
473
|
+
resource,
|
|
474
|
+
upload,
|
|
364
475
|
view,
|
|
365
476
|
};
|
package/src/ws/BaseHandler.js
CHANGED
|
@@ -20,12 +20,12 @@ class BaseHandler {
|
|
|
20
20
|
*/
|
|
21
21
|
async handleMessage(socket, message) {
|
|
22
22
|
const validationResult = await this.validateMessage(message, socket);
|
|
23
|
-
if (validationResult === false) {
|
|
24
|
-
throw new Error('Invalid message');
|
|
25
|
-
}
|
|
26
|
-
const guard = require('./HandlerGuard').guards.get(socket);
|
|
27
|
-
if (guard) await guard();
|
|
28
|
-
return this.onMessage(socket, message);
|
|
23
|
+
if (validationResult === false) {
|
|
24
|
+
throw new Error('Invalid message');
|
|
25
|
+
}
|
|
26
|
+
const guard = require('./HandlerGuard').guards.get(socket);
|
|
27
|
+
if (guard) await guard();
|
|
28
|
+
return this.onMessage(socket, message);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
validateMessage() {
|
|
@@ -1,207 +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 };
|
|
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/HandlerGuard.js
CHANGED
|
@@ -1,4 +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 };
|
|
1
|
+
'use strict';
|
|
2
|
+
// A private hook, never a field supplied by an incoming message.
|
|
3
|
+
const guards = new WeakMap();
|
|
4
|
+
module.exports = { guards };
|