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/PageManager.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const { createHash, randomUUID } = require('crypto');
|
|
2
|
+
const { Transform } = require('stream');
|
|
3
|
+
const { finished } = require('stream/promises');
|
|
4
|
+
const PageTaskLane = require('./PageTaskLane');
|
|
2
5
|
const { RequestFailure } = require('../access/RequestFailure');
|
|
3
6
|
const path = require('path');
|
|
4
7
|
const { setMaxListeners } = require('events');
|
|
@@ -10,7 +13,7 @@ const LivePage = require('./LivePage');
|
|
|
10
13
|
const ReactiveRenderer = require('./ReactiveRenderer');
|
|
11
14
|
const browserRuntime = require('./browserRuntime');
|
|
12
15
|
const { isHtml, renderValue, trustedHtml } = require('./Html');
|
|
13
|
-
const { getPageMetadata, getPageStylesheetRoots, getPageTemplateRoot } = require('./metadata');
|
|
16
|
+
const { getActionImplementation, getInjectMetadata, getPageMetadata, getPageStylesheetRoots, getPageTemplateRoot, getUploadMetadata } = require('./metadata');
|
|
14
17
|
const synchronous = require('./synchronous');
|
|
15
18
|
const { AccessDenied } = require('../access/AccessPolicy');
|
|
16
19
|
const { PageIdentity, AuthenticationFailure, isPrincipal } = require('./PageIdentity');
|
|
@@ -25,6 +28,7 @@ const DEFAULT_PATHS = Object.freeze({
|
|
|
25
28
|
client: '/__redweb/client.js',
|
|
26
29
|
runtime: '/__redweb/runtime.js',
|
|
27
30
|
css: '/__redweb/css',
|
|
31
|
+
upload: '/__redweb/upload',
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
function boundedName(value, label) {
|
|
@@ -70,7 +74,7 @@ function matchesIfNoneMatch(header, etag) {
|
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
class PageManager {
|
|
73
|
-
constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, heartbeat = DEFAULT_HEARTBEAT, authenticate, authenticationTimeoutMs, origins, logger = console }, reservedPaths = {}) {
|
|
77
|
+
constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, uploadTimeoutMs = 30_000, heartbeat = DEFAULT_HEARTBEAT, authenticate, authenticationTimeoutMs, origins, providers = {}, logger = console }, reservedPaths = {}) {
|
|
74
78
|
if (!Array.isArray(pages) || pages.length === 0) throw new TypeError('`pages` must be a non-empty array.');
|
|
75
79
|
if (templateRoot !== undefined && (typeof templateRoot !== 'string' || !templateRoot)) throw new TypeError('`templateRoot` must be a non-empty string.');
|
|
76
80
|
if (!Number.isInteger(sessionTtlMs) || sessionTtlMs < 0) throw new TypeError('`sessionTtlMs` must be a non-negative integer.');
|
|
@@ -79,11 +83,18 @@ class PageManager {
|
|
|
79
83
|
throw new TypeError('`maxConcurrentRenders` must be a positive integer.');
|
|
80
84
|
}
|
|
81
85
|
if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
|
|
86
|
+
if (!Number.isInteger(uploadTimeoutMs) || uploadTimeoutMs < 1 || uploadTimeoutMs > 300_000) throw new TypeError('`uploadTimeoutMs` must be an integer between 1 and 300000.');
|
|
82
87
|
if (!paths || typeof paths !== 'object' || Array.isArray(paths)) throw new TypeError('`paths` must be an object.');
|
|
83
88
|
if (origins !== undefined && typeof origins !== 'function' &&
|
|
84
89
|
(!Array.isArray(origins) || origins.some(origin => typeof origin !== 'string' || !origin))) {
|
|
85
90
|
throw new TypeError('`origins` must be a function or an array of non-empty origins.');
|
|
86
91
|
}
|
|
92
|
+
if (!providers || typeof providers !== 'object' || Array.isArray(providers) || Object.getPrototypeOf(providers) !== Object.prototype) {
|
|
93
|
+
throw new TypeError('`providers` must be a plain object.');
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(providers).some(name => !/^[A-Za-z_$][\w$-]{0,127}$/.test(name) || ['__proto__', 'prototype', 'constructor'].includes(name))) {
|
|
96
|
+
throw new TypeError('Provider names must be safe identifiers of at most 128 characters.');
|
|
97
|
+
}
|
|
87
98
|
this.paths = { ...DEFAULT_PATHS, ...paths, ...reservedPaths };
|
|
88
99
|
Object.entries(this.paths).forEach(([name, value]) => {
|
|
89
100
|
internalPath(value, name);
|
|
@@ -103,8 +114,10 @@ class PageManager {
|
|
|
103
114
|
this.maxSessions = maxSessions;
|
|
104
115
|
this.maxConcurrentRenders = maxConcurrentRenders;
|
|
105
116
|
this.shutdownTimeoutMs = shutdownTimeoutMs;
|
|
117
|
+
this.uploadTimeoutMs = uploadTimeoutMs;
|
|
106
118
|
this.heartbeat = heartbeat;
|
|
107
119
|
this.logger = logger || { log() {}, warn() {}, error() {} };
|
|
120
|
+
this.providers = Object.freeze({ ...providers });
|
|
108
121
|
this.authenticateRequest = authenticate;
|
|
109
122
|
this.identity = new PageIdentity(authenticate, authenticationTimeoutMs);
|
|
110
123
|
this.lifetimes = new Set();
|
|
@@ -167,6 +180,11 @@ class PageManager {
|
|
|
167
180
|
instantiate(record) {
|
|
168
181
|
const instance = new record.PageClass();
|
|
169
182
|
if (!(instance instanceof record.PageClass)) throw new TypeError('Page construction returned an incompatible object.');
|
|
183
|
+
getInjectMetadata(record.PageClass).forEach((provider, property) => {
|
|
184
|
+
if (!Object.hasOwn(this.providers, provider)) throw new Error(`Page requires missing provider "${provider}".`);
|
|
185
|
+
if (instance[property] !== undefined) throw new Error(`Injected property "${property}" must not have an initializer.`);
|
|
186
|
+
Object.defineProperty(instance, property, { configurable: false, enumerable: true, writable: false, value: this.providers[provider] });
|
|
187
|
+
});
|
|
170
188
|
const page = LivePage.adopt(instance);
|
|
171
189
|
page._activateState();
|
|
172
190
|
return page;
|
|
@@ -177,6 +195,11 @@ class PageManager {
|
|
|
177
195
|
const clientFile = path.join(path.dirname(require.resolve('redweb-client/live-html')), 'live-html.js');
|
|
178
196
|
app.get(this.paths.client, (_request, response) => response.sendFile(clientFile));
|
|
179
197
|
app.get(this.paths.runtime, (_request, response) => response.type('text/javascript').send(browserRuntime(this.paths.client)));
|
|
198
|
+
app.post(this.paths.upload, (request, response) => this.receiveUpload(request, response).catch(error => {
|
|
199
|
+
if (response.headersSent) return response.destroy();
|
|
200
|
+
const failure = RequestFailure.from(error, 'UPLOAD_FAILED');
|
|
201
|
+
response.status(failure.status).json({ error: { code: failure.code, message: failure.message } });
|
|
202
|
+
}));
|
|
180
203
|
}
|
|
181
204
|
this.stylesheets.forEach((content, url) => app.get(url, (_request, response) => {
|
|
182
205
|
response.set('Cache-Control', 'public, max-age=31536000, immutable').type('text/css').send(content);
|
|
@@ -265,10 +288,10 @@ class PageManager {
|
|
|
265
288
|
if (!isHtml(result)) throw new TypeError('Page layouts must return html.');
|
|
266
289
|
return renderValue(result);
|
|
267
290
|
};
|
|
268
|
-
const withContext = callback => require('./SocketAction').withRoute(record.socketHandlers,
|
|
269
|
-
() => LivePage.withRenderContext(context, callback));
|
|
270
|
-
const markup = await lifetime.wait(() => renderer ? renderer.initialize(render, withContext) : withContext(render));
|
|
271
|
-
if (record.metadata.socket && !renderer.enabled) throw new TypeError('Socket-bound pages must render TSX.');
|
|
291
|
+
const withContext = callback => require('./SocketAction').withRoute(record.socketHandlers,
|
|
292
|
+
() => LivePage.withRenderContext(context, callback));
|
|
293
|
+
const markup = await lifetime.wait(() => renderer ? renderer.initialize(render, withContext) : withContext(render));
|
|
294
|
+
if (record.metadata.socket && !renderer.enabled) throw new TypeError('Socket-bound pages must render TSX.');
|
|
272
295
|
const document = this.createDocument(record, request);
|
|
273
296
|
if (record.metadata.live === false) {
|
|
274
297
|
const result = document(markup, null);
|
|
@@ -279,8 +302,9 @@ class PageManager {
|
|
|
279
302
|
session.renderLifetime = renderer;
|
|
280
303
|
const config = {
|
|
281
304
|
pageId: session.id,
|
|
282
|
-
socketPath: record.socketPath || this.paths.socket,
|
|
305
|
+
socketPath: record.socketPath || this.paths.socket,
|
|
283
306
|
runtimePath: this.paths.runtime,
|
|
307
|
+
uploadPath: this.paths.upload,
|
|
284
308
|
version: PROTOCOL_VERSION,
|
|
285
309
|
};
|
|
286
310
|
if (renderer.enabled) {
|
|
@@ -309,7 +333,8 @@ class PageManager {
|
|
|
309
333
|
|
|
310
334
|
createSession(page, ownsPage, principal, context = {}, record, lifetime = this.createLifetime(principal)) {
|
|
311
335
|
const id = randomUUID();
|
|
312
|
-
const session = { id, page, ownsPage, principal, context, record, lifetime, socket: null, timer: null, detaching: null
|
|
336
|
+
const session = { id, page, ownsPage, principal, context, record, lifetime, socket: null, timer: null, detaching: null,
|
|
337
|
+
tasks: new PageTaskLane() };
|
|
313
338
|
lifetime.session = session;
|
|
314
339
|
this.pending.set(id, session);
|
|
315
340
|
this.expire(session);
|
|
@@ -324,7 +349,7 @@ class PageManager {
|
|
|
324
349
|
session.timer.unref?.();
|
|
325
350
|
}
|
|
326
351
|
|
|
327
|
-
async authenticate(request, RouteClass) {
|
|
352
|
+
async authenticate(request, RouteClass) {
|
|
328
353
|
let id;
|
|
329
354
|
try {
|
|
330
355
|
id = new URL(request.url, `http://${request.headers.host || 'localhost'}`).searchParams.get('pageId');
|
|
@@ -332,9 +357,9 @@ class PageManager {
|
|
|
332
357
|
return false;
|
|
333
358
|
}
|
|
334
359
|
if (typeof id !== 'string' || id.length > 128) return false;
|
|
335
|
-
const session = this.pending.get(id) || this.active.get(id);
|
|
336
|
-
if (!session || session.socket || session.detaching) return false;
|
|
337
|
-
if (session.record?.metadata.socket !== RouteClass) return false;
|
|
360
|
+
const session = this.pending.get(id) || this.active.get(id);
|
|
361
|
+
if (!session || session.socket || session.detaching) return false;
|
|
362
|
+
if (session.record?.metadata.socket !== RouteClass) return false;
|
|
338
363
|
try {
|
|
339
364
|
const principal = await this.identity.resolve(request, session.lifetime.signal);
|
|
340
365
|
if (!Object.is(principal, session.principal)) return false;
|
|
@@ -388,7 +413,7 @@ class PageManager {
|
|
|
388
413
|
connectionContext(session, socket) { return Object.freeze({ ...session.context, socket, signal: session.connection.signal, principal: session.principal }); }
|
|
389
414
|
|
|
390
415
|
checkConnected(session, socket) {
|
|
391
|
-
if (!this.available(session) || session.socket !== socket || (socket.readyState !== undefined && socket.readyState !== 1)) throw new AccessDenied('ACCESS_CANCELLED');
|
|
416
|
+
if (!this.available(session) || session.socket !== socket || (socket.readyState !== undefined && socket.readyState !== 1)) throw new AccessDenied('ACCESS_CANCELLED');
|
|
392
417
|
session.connection.check();
|
|
393
418
|
}
|
|
394
419
|
|
|
@@ -416,6 +441,7 @@ class PageManager {
|
|
|
416
441
|
|
|
417
442
|
async release(session) {
|
|
418
443
|
session.lifetime.revoked = true;
|
|
444
|
+
session.tasks.close(new AccessDenied('ACCESS_CANCELLED'));
|
|
419
445
|
const cleanup = [];
|
|
420
446
|
if (session.socket) {
|
|
421
447
|
session.socket.terminate?.();
|
|
@@ -459,9 +485,19 @@ class PageManager {
|
|
|
459
485
|
return affected.length;
|
|
460
486
|
}
|
|
461
487
|
|
|
488
|
+
enqueue(session, task) {
|
|
489
|
+
const result = session.tasks.enqueue(task);
|
|
490
|
+
if (!result) throw new RequestFailure('ACCESS_CAPACITY');
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
|
|
462
494
|
async receive(socket, message) {
|
|
463
495
|
const session = socket.__redwebPageSession;
|
|
464
496
|
if (!session) throw new Error('Page session is not connected.');
|
|
497
|
+
return this.enqueue(session, () => this.receiveMessage(session, socket, message));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async receiveMessage(session, socket, message) {
|
|
465
501
|
this.checkConnected(session, socket);
|
|
466
502
|
const payload = message.payload;
|
|
467
503
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new TypeError('Live HTML payload must be an object.');
|
|
@@ -487,6 +523,83 @@ class PageManager {
|
|
|
487
523
|
throw new TypeError('Live HTML message kind must be "action" or "state".');
|
|
488
524
|
}
|
|
489
525
|
|
|
526
|
+
async receiveUpload(request, response) {
|
|
527
|
+
const address = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
|
|
528
|
+
const id = address.searchParams.get('pageId');
|
|
529
|
+
const name = boundedName(address.searchParams.get('action'), 'Upload action name');
|
|
530
|
+
const component = address.searchParams.get('component');
|
|
531
|
+
const session = typeof id === 'string' ? this.pending.get(id) || this.active.get(id) : null;
|
|
532
|
+
if (!session || !this.available(session)) throw new AccessDenied('ACCESS_DENIED');
|
|
533
|
+
if (request.headers.origin !== undefined && !await this.acceptsOrigin(request.headers.origin, request)) throw new RequestFailure('ORIGIN_DENIED');
|
|
534
|
+
if (request.headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(request.headers['sec-fetch-site'])) {
|
|
535
|
+
throw new RequestFailure('ORIGIN_DENIED');
|
|
536
|
+
}
|
|
537
|
+
return this.enqueue(session, () => this.receiveUploadTask(session, request, response, name, component));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async receiveUploadTask(session, request, response, name, component) {
|
|
541
|
+
session.lifetime.check();
|
|
542
|
+
let stream;
|
|
543
|
+
let timer;
|
|
544
|
+
let cancelled = request.destroyed || response.destroyed || session.lifetime.signal.aborted;
|
|
545
|
+
const cancel = () => { cancelled = true; stream?.destroy(new AccessDenied('ACCESS_CANCELLED')); };
|
|
546
|
+
request.once('aborted', cancel);
|
|
547
|
+
request.once('error', cancel);
|
|
548
|
+
response.once('close', cancel);
|
|
549
|
+
session.lifetime.signal.addEventListener('abort', cancel, { once: true });
|
|
550
|
+
try {
|
|
551
|
+
if (cancelled) throw new AccessDenied('ACCESS_CANCELLED');
|
|
552
|
+
const principal = await this.identity.resolve(request, session.lifetime.signal);
|
|
553
|
+
if (!Object.is(principal, session.principal)) throw new AccessDenied('ACCESS_DENIED');
|
|
554
|
+
const snapshot = requestSnapshot(request);
|
|
555
|
+
const context = Object.freeze({ ...session.context, request: snapshot, params: snapshot.params, query: snapshot.query,
|
|
556
|
+
body: snapshot.body, principal, signal: session.lifetime.signal });
|
|
557
|
+
await session.record.metadata.policy?.check(context);
|
|
558
|
+
session.lifetime.check();
|
|
559
|
+
if (cancelled || request.destroyed || response.destroyed) throw new AccessDenied('ACCESS_CANCELLED');
|
|
560
|
+
const target = component === null || component === '' ? session.page : session.page._component(boundedName(component, 'Upload component name'));
|
|
561
|
+
if (!target) throw new AccessDenied('ACCESS_DENIED');
|
|
562
|
+
const config = getUploadMetadata(target.constructor).get(name);
|
|
563
|
+
const implementation = getActionImplementation(target.constructor, name);
|
|
564
|
+
if (!config || !implementation || target[name] !== implementation) throw new AccessDenied('ACCESS_DENIED');
|
|
565
|
+
const contentLength = Number(request.headers['content-length']);
|
|
566
|
+
if (Number.isFinite(contentLength) && contentLength > config.maxBytes) throw new RequestFailure('UPLOAD_TOO_LARGE');
|
|
567
|
+
const contentType = String(request.headers['content-type'] || '').split(';', 1)[0].toLowerCase();
|
|
568
|
+
if (config.accept.length && !config.accept.some(type => type === contentType || type.endsWith('/*') && contentType.startsWith(type.slice(0, -1)))) {
|
|
569
|
+
throw new RequestFailure('UPLOAD_TYPE_REJECTED');
|
|
570
|
+
}
|
|
571
|
+
let bytes = 0;
|
|
572
|
+
stream = request.pipe(new Transform({ transform(chunk, _encoding, done) {
|
|
573
|
+
bytes += chunk.length;
|
|
574
|
+
if (bytes > config.maxBytes) return done(new RequestFailure('UPLOAD_TOO_LARGE'));
|
|
575
|
+
done(null, chunk);
|
|
576
|
+
} }));
|
|
577
|
+
stream.once('error', () => {});
|
|
578
|
+
timer = setTimeout(() => stream.destroy(new RequestFailure('UPLOAD_TIMEOUT')), this.uploadTimeoutMs);
|
|
579
|
+
timer.unref?.();
|
|
580
|
+
const uploadedName = request.headers['x-redweb-upload-name'];
|
|
581
|
+
let decodedName = null;
|
|
582
|
+
if (typeof uploadedName === 'string' && uploadedName.length > 0 && uploadedName.length <= 768) {
|
|
583
|
+
try { decodedName = decodeURIComponent(uploadedName); }
|
|
584
|
+
catch { decodedName = null; }
|
|
585
|
+
}
|
|
586
|
+
const file = Object.freeze({ stream, type: contentType,
|
|
587
|
+
name: decodedName && decodedName.length <= 256 ? decodedName : null });
|
|
588
|
+
await implementation.call(target, file, context);
|
|
589
|
+
if (!stream.readableEnded) {
|
|
590
|
+
stream.resume();
|
|
591
|
+
await finished(stream);
|
|
592
|
+
}
|
|
593
|
+
response.status(204).end();
|
|
594
|
+
} finally {
|
|
595
|
+
request.off('aborted', cancel);
|
|
596
|
+
request.off('error', cancel);
|
|
597
|
+
response.off('close', cancel);
|
|
598
|
+
session.lifetime.signal.removeEventListener('abort', cancel);
|
|
599
|
+
clearTimeout(timer);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
490
603
|
route() {
|
|
491
604
|
const manager = this;
|
|
492
605
|
class LiveHtmlHandler extends BaseHandler {
|
|
@@ -1,132 +1,132 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { AdmissionPolicy, ADMISSION_CONTEXT } = require('../ws/AdmissionPolicy');
|
|
4
|
-
const { AccessDenied } = require('../access/AccessPolicy');
|
|
5
|
-
const TransportPolicy = require('../ws/TransportPolicy');
|
|
6
|
-
const { guards } = require('../ws/HandlerGuard');
|
|
7
|
-
const { scheduleStartupCleanup } = require('../StartupCleanup');
|
|
8
|
-
|
|
9
|
-
/** Adds page ownership to a route without replacing its admission, handlers or transport. */
|
|
10
|
-
function bindRoute(manager, RouteClass, records) {
|
|
11
|
-
const sessions = new WeakMap();
|
|
12
|
-
const ready = new WeakMap();
|
|
13
|
-
return class PageSocketRoute extends RouteClass {
|
|
14
|
-
constructor() {
|
|
15
|
-
super();
|
|
16
|
-
try {
|
|
17
|
-
if (!this.protocolPolicy?.versions.includes('1') || this.protocolPolicy.queryParameter !== 'redwebVersion') {
|
|
18
|
-
throw new TypeError('Page socket routes must support protocol version 1 and the default version query parameter.');
|
|
19
|
-
}
|
|
20
|
-
if (this.handlers.some(handler => handler.name.startsWith('redweb:'))) throw new TypeError('Page socket routes reserve redweb:* message types.');
|
|
21
|
-
if (!this.allowDuplicateConnections) throw new TypeError('Page socket routes require allowDuplicateConnections: true for independent browser tabs.');
|
|
22
|
-
this.transportPolicy = new TransportPolicy(this.transportPolicy || {}, true);
|
|
23
|
-
this.runtime.inFlight ||= new Set();
|
|
24
|
-
this.inFlight = this.runtime.inFlight;
|
|
25
|
-
for (const handler of this.handlers) {
|
|
26
|
-
const initial = handler.onInitialContact;
|
|
27
|
-
handler.onInitialContact = async (socket, request) => {
|
|
28
|
-
await ready.get(socket).promise;
|
|
29
|
-
if (socket.context.signal.aborted) throw new AccessDenied('ACCESS_CANCELLED');
|
|
30
|
-
return initial?.call(handler, socket, request);
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
this.pageAdmission = new AdmissionPolicy({
|
|
34
|
-
origins: (origin, request) => manager.acceptsOrigin(origin, request),
|
|
35
|
-
authenticate: async request => {
|
|
36
|
-
const session = await manager.authenticate(request, RouteClass);
|
|
37
|
-
if (!session) return false;
|
|
38
|
-
sessions.set(request, session);
|
|
39
|
-
return request[ADMISSION_CONTEXT]?.principal ?? true;
|
|
40
|
-
},
|
|
41
|
-
});
|
|
42
|
-
for (const record of records) {
|
|
43
|
-
record.socketPath = this.path;
|
|
44
|
-
record.socketHandlers = new Set(this.handlers.map(handler => handler.constructor));
|
|
45
|
-
}
|
|
46
|
-
} catch (error) {
|
|
47
|
-
throw scheduleStartupCleanup(error, () => this.shutdown());
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
handleConnection(socket, request) {
|
|
52
|
-
let resolve, reject;
|
|
53
|
-
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
54
|
-
promise.catch(() => {});
|
|
55
|
-
ready.set(socket, { promise, resolve, reject });
|
|
56
|
-
return super.handleConnection(socket, request);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async authorizeUpgrade(request, rawSocket, signal) {
|
|
60
|
-
if (!await super.authorizeUpgrade(request, rawSocket, signal)) return false;
|
|
61
|
-
if (!new URL(request.url, 'http://localhost').searchParams.has('pageId')) return true;
|
|
62
|
-
return this.pageAdmission.authorize(request, rawSocket, this, signal);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async connectionOpenCallback(socket, request) {
|
|
66
|
-
try {
|
|
67
|
-
const session = sessions.get(request);
|
|
68
|
-
if (session) {
|
|
69
|
-
Object.defineProperty(socket, 'page', { value: PageClass => {
|
|
70
|
-
manager.checkConnected(session, socket);
|
|
71
|
-
if (!(session.page instanceof PageClass)) throw new TypeError('This connection does not own the requested page.');
|
|
72
|
-
return session.page;
|
|
73
|
-
} });
|
|
74
|
-
guards.set(socket, () => manager.authorize(session, socket));
|
|
75
|
-
session.renderer.authorize = () => manager.authorize(session, socket);
|
|
76
|
-
await manager.connect(session, socket);
|
|
77
|
-
}
|
|
78
|
-
await super.connectionOpenCallback(socket, request);
|
|
79
|
-
ready.get(socket).resolve();
|
|
80
|
-
} catch (error) { ready.get(socket).reject(error); throw error; }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async handleMessage(socket, message) {
|
|
84
|
-
try {
|
|
85
|
-
await ready.get(socket).promise;
|
|
86
|
-
const session = socket.__redwebPageSession;
|
|
87
|
-
if (!session) return super.handleMessage(socket, message);
|
|
88
|
-
await manager.authorize(session, socket);
|
|
89
|
-
// Only the bound route's registered commands are accepted. Live action/state
|
|
90
|
-
// envelopes cannot bypass its handlers or mutate page fields.
|
|
91
|
-
const accepted = await super.handleMessage(socket, message);
|
|
92
|
-
if (accepted && message.requestId !== undefined) {
|
|
93
|
-
manager.checkConnected(session, socket);
|
|
94
|
-
socket.sendEvent('redweb:result', null, { requestId: message.requestId });
|
|
95
|
-
}
|
|
96
|
-
return accepted;
|
|
97
|
-
} catch (error) {
|
|
98
|
-
if (!(error instanceof AccessDenied)) throw error;
|
|
99
|
-
this.sendAccessFailure(socket, error, { requestId: message?.requestId });
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async connectionCloseCallback(socket) {
|
|
105
|
-
// Keep the weak guard: validators already in flight must still fail after close.
|
|
106
|
-
try { await manager.disconnect(socket); }
|
|
107
|
-
finally { await super.connectionCloseCallback?.(socket); }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async handleBinaryMessage(socket, buffer) {
|
|
111
|
-
await ready.get(socket).promise;
|
|
112
|
-
if (socket.__redwebPageSession) {
|
|
113
|
-
socket.close(1008, 'Page sockets accept JSON commands only');
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
return super.handleBinaryMessage(socket, buffer);
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function bindRoutes(manager, RouteClasses) {
|
|
122
|
-
const attached = [...manager.records.values()].filter(record => record.metadata.socket);
|
|
123
|
-
for (const record of attached) {
|
|
124
|
-
if (!RouteClasses.includes(record.metadata.socket)) throw new TypeError('Every page socket route must be registered in the application.');
|
|
125
|
-
}
|
|
126
|
-
return RouteClasses.map(RouteClass => {
|
|
127
|
-
const records = attached.filter(record => record.metadata.socket === RouteClass);
|
|
128
|
-
return records.length ? bindRoute(manager, RouteClass, records) : RouteClass;
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
module.exports = { bindRoutes };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AdmissionPolicy, ADMISSION_CONTEXT } = require('../ws/AdmissionPolicy');
|
|
4
|
+
const { AccessDenied } = require('../access/AccessPolicy');
|
|
5
|
+
const TransportPolicy = require('../ws/TransportPolicy');
|
|
6
|
+
const { guards } = require('../ws/HandlerGuard');
|
|
7
|
+
const { scheduleStartupCleanup } = require('../StartupCleanup');
|
|
8
|
+
|
|
9
|
+
/** Adds page ownership to a route without replacing its admission, handlers or transport. */
|
|
10
|
+
function bindRoute(manager, RouteClass, records) {
|
|
11
|
+
const sessions = new WeakMap();
|
|
12
|
+
const ready = new WeakMap();
|
|
13
|
+
return class PageSocketRoute extends RouteClass {
|
|
14
|
+
constructor() {
|
|
15
|
+
super();
|
|
16
|
+
try {
|
|
17
|
+
if (!this.protocolPolicy?.versions.includes('1') || this.protocolPolicy.queryParameter !== 'redwebVersion') {
|
|
18
|
+
throw new TypeError('Page socket routes must support protocol version 1 and the default version query parameter.');
|
|
19
|
+
}
|
|
20
|
+
if (this.handlers.some(handler => handler.name.startsWith('redweb:'))) throw new TypeError('Page socket routes reserve redweb:* message types.');
|
|
21
|
+
if (!this.allowDuplicateConnections) throw new TypeError('Page socket routes require allowDuplicateConnections: true for independent browser tabs.');
|
|
22
|
+
this.transportPolicy = new TransportPolicy(this.transportPolicy || {}, true);
|
|
23
|
+
this.runtime.inFlight ||= new Set();
|
|
24
|
+
this.inFlight = this.runtime.inFlight;
|
|
25
|
+
for (const handler of this.handlers) {
|
|
26
|
+
const initial = handler.onInitialContact;
|
|
27
|
+
handler.onInitialContact = async (socket, request) => {
|
|
28
|
+
await ready.get(socket).promise;
|
|
29
|
+
if (socket.context.signal.aborted) throw new AccessDenied('ACCESS_CANCELLED');
|
|
30
|
+
return initial?.call(handler, socket, request);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
this.pageAdmission = new AdmissionPolicy({
|
|
34
|
+
origins: (origin, request) => manager.acceptsOrigin(origin, request),
|
|
35
|
+
authenticate: async request => {
|
|
36
|
+
const session = await manager.authenticate(request, RouteClass);
|
|
37
|
+
if (!session) return false;
|
|
38
|
+
sessions.set(request, session);
|
|
39
|
+
return request[ADMISSION_CONTEXT]?.principal ?? true;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
for (const record of records) {
|
|
43
|
+
record.socketPath = this.path;
|
|
44
|
+
record.socketHandlers = new Set(this.handlers.map(handler => handler.constructor));
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw scheduleStartupCleanup(error, () => this.shutdown());
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
handleConnection(socket, request) {
|
|
52
|
+
let resolve, reject;
|
|
53
|
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
54
|
+
promise.catch(() => {});
|
|
55
|
+
ready.set(socket, { promise, resolve, reject });
|
|
56
|
+
return super.handleConnection(socket, request);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async authorizeUpgrade(request, rawSocket, signal) {
|
|
60
|
+
if (!await super.authorizeUpgrade(request, rawSocket, signal)) return false;
|
|
61
|
+
if (!new URL(request.url, 'http://localhost').searchParams.has('pageId')) return true;
|
|
62
|
+
return this.pageAdmission.authorize(request, rawSocket, this, signal);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async connectionOpenCallback(socket, request) {
|
|
66
|
+
try {
|
|
67
|
+
const session = sessions.get(request);
|
|
68
|
+
if (session) {
|
|
69
|
+
Object.defineProperty(socket, 'page', { value: PageClass => {
|
|
70
|
+
manager.checkConnected(session, socket);
|
|
71
|
+
if (!(session.page instanceof PageClass)) throw new TypeError('This connection does not own the requested page.');
|
|
72
|
+
return session.page;
|
|
73
|
+
} });
|
|
74
|
+
guards.set(socket, () => manager.authorize(session, socket));
|
|
75
|
+
session.renderer.authorize = () => manager.authorize(session, socket);
|
|
76
|
+
await manager.connect(session, socket);
|
|
77
|
+
}
|
|
78
|
+
await super.connectionOpenCallback(socket, request);
|
|
79
|
+
ready.get(socket).resolve();
|
|
80
|
+
} catch (error) { ready.get(socket).reject(error); throw error; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async handleMessage(socket, message) {
|
|
84
|
+
try {
|
|
85
|
+
await ready.get(socket).promise;
|
|
86
|
+
const session = socket.__redwebPageSession;
|
|
87
|
+
if (!session) return super.handleMessage(socket, message);
|
|
88
|
+
await manager.authorize(session, socket);
|
|
89
|
+
// Only the bound route's registered commands are accepted. Live action/state
|
|
90
|
+
// envelopes cannot bypass its handlers or mutate page fields.
|
|
91
|
+
const accepted = await super.handleMessage(socket, message);
|
|
92
|
+
if (accepted && message.requestId !== undefined) {
|
|
93
|
+
manager.checkConnected(session, socket);
|
|
94
|
+
socket.sendEvent('redweb:result', null, { requestId: message.requestId });
|
|
95
|
+
}
|
|
96
|
+
return accepted;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (!(error instanceof AccessDenied)) throw error;
|
|
99
|
+
this.sendAccessFailure(socket, error, { requestId: message?.requestId });
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async connectionCloseCallback(socket) {
|
|
105
|
+
// Keep the weak guard: validators already in flight must still fail after close.
|
|
106
|
+
try { await manager.disconnect(socket); }
|
|
107
|
+
finally { await super.connectionCloseCallback?.(socket); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async handleBinaryMessage(socket, buffer) {
|
|
111
|
+
await ready.get(socket).promise;
|
|
112
|
+
if (socket.__redwebPageSession) {
|
|
113
|
+
socket.close(1008, 'Page sockets accept JSON commands only');
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return super.handleBinaryMessage(socket, buffer);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function bindRoutes(manager, RouteClasses) {
|
|
122
|
+
const attached = [...manager.records.values()].filter(record => record.metadata.socket);
|
|
123
|
+
for (const record of attached) {
|
|
124
|
+
if (!RouteClasses.includes(record.metadata.socket)) throw new TypeError('Every page socket route must be registered in the application.');
|
|
125
|
+
}
|
|
126
|
+
return RouteClasses.map(RouteClass => {
|
|
127
|
+
const records = attached.filter(record => record.metadata.socket === RouteClass);
|
|
128
|
+
return records.length ? bindRoute(manager, RouteClass, records) : RouteClass;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { bindRoutes };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Serializes all browser work for one live page, regardless of transport. */
|
|
4
|
+
class PageTaskLane {
|
|
5
|
+
constructor(maxPending = 64) {
|
|
6
|
+
this.maxPending = maxPending;
|
|
7
|
+
this.tasks = [];
|
|
8
|
+
this.running = false;
|
|
9
|
+
this.closed = false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
enqueue(task) {
|
|
13
|
+
if (typeof task !== 'function') throw new TypeError('Page tasks must be functions.');
|
|
14
|
+
if (this.closed || this.tasks.length + (this.running ? 1 : 0) >= this.maxPending) return null;
|
|
15
|
+
let resolve, reject;
|
|
16
|
+
const result = new Promise((accept, deny) => { resolve = accept; reject = deny; });
|
|
17
|
+
this.tasks.push({ task, resolve, reject });
|
|
18
|
+
if (!this.running) void this.drain();
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async drain() {
|
|
23
|
+
this.running = true;
|
|
24
|
+
while (!this.closed && this.tasks.length) {
|
|
25
|
+
const current = this.tasks.shift();
|
|
26
|
+
try { current.resolve(await current.task()); }
|
|
27
|
+
catch (error) { current.reject(error); }
|
|
28
|
+
}
|
|
29
|
+
this.running = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
close(error = new Error('Page task lane closed.')) {
|
|
33
|
+
if (this.closed) return;
|
|
34
|
+
this.closed = true;
|
|
35
|
+
this.tasks.splice(0).forEach(task => task.reject(error));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = PageTaskLane;
|
|
@@ -222,14 +222,14 @@ class ReactiveRenderer {
|
|
|
222
222
|
const node = this.nodes.get(ownerId(payload.component));
|
|
223
223
|
return node && [...node.html.matchAll(/\b(?:data-rw-state|rw-bind)\s*=\s*["']([^"']+)["']/g)].some(match => match[1] === payload.name);
|
|
224
224
|
});
|
|
225
|
-
if (!this.disposed && generation === this.generation && socket && (patches.length || explicit.length)) {
|
|
226
|
-
try { if (this.authorize) await this.authorize(); }
|
|
227
|
-
catch (error) {
|
|
228
|
-
if (this.disposed || generation !== this.generation) return;
|
|
229
|
-
throw error;
|
|
230
|
-
}
|
|
231
|
-
if (this.disposed || generation !== this.generation) return;
|
|
232
|
-
socket.sendEvent('redweb:patch', { patches, states: explicit });
|
|
225
|
+
if (!this.disposed && generation === this.generation && socket && (patches.length || explicit.length)) {
|
|
226
|
+
try { if (this.authorize) await this.authorize(); }
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (this.disposed || generation !== this.generation) return;
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
if (this.disposed || generation !== this.generation) return;
|
|
232
|
+
socket.sendEvent('redweb:patch', { patches, states: explicit });
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
package/src/htmx/SocketAction.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { AsyncLocalStorage } = require('async_hooks');
|
|
4
|
-
const { bindings } = require('../ws/SocketAction');
|
|
5
|
-
const routes = new AsyncLocalStorage();
|
|
6
|
-
|
|
7
|
-
function attributes(properties) {
|
|
8
|
-
let result = properties;
|
|
9
|
-
for (const name of ['rw-click', 'rw-submit']) {
|
|
10
|
-
const binding = bindings.get(properties[name]);
|
|
11
|
-
if (!binding) continue;
|
|
12
|
-
if (!routes.getStore()?.has(binding.Handler)) throw new TypeError('Socket action handler must belong to this page socket route.');
|
|
13
|
-
if (Object.keys(result).some(key => key.toLowerCase() === 'data-rw-command')) throw new TypeError('Only one socket action may bind a control.');
|
|
14
|
-
result = { ...result, [name]: binding.type, 'data-rw-command': JSON.stringify({ type: binding.type, payload: binding.payload }) };
|
|
15
|
-
}
|
|
16
|
-
return result;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
module.exports = { attributes, withRoute: (handlers, render) => routes.run(handlers, render) };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
4
|
+
const { bindings } = require('../ws/SocketAction');
|
|
5
|
+
const routes = new AsyncLocalStorage();
|
|
6
|
+
|
|
7
|
+
function attributes(properties) {
|
|
8
|
+
let result = properties;
|
|
9
|
+
for (const name of ['rw-click', 'rw-submit']) {
|
|
10
|
+
const binding = bindings.get(properties[name]);
|
|
11
|
+
if (!binding) continue;
|
|
12
|
+
if (!routes.getStore()?.has(binding.Handler)) throw new TypeError('Socket action handler must belong to this page socket route.');
|
|
13
|
+
if (Object.keys(result).some(key => key.toLowerCase() === 'data-rw-command')) throw new TypeError('Only one socket action may bind a control.');
|
|
14
|
+
result = { ...result, [name]: binding.type, 'data-rw-command': JSON.stringify({ type: binding.type, payload: binding.payload }) };
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { attributes, withRoute: (handlers, render) => routes.run(handlers, render) };
|
|
@@ -13,7 +13,7 @@ const BINDING = /{{\s*([A-Za-z_$][\w$]*)\s*}}/g;
|
|
|
13
13
|
const ATTRIBUTE_BINDING = /{{\s*[A-Za-z_$][\w$]*\s*}}/;
|
|
14
14
|
const NAME = /^[A-Za-z_$][\w$]*$/;
|
|
15
15
|
const DIRECTIVES = new Set(['data-rw-state', 'data-rw-html', 'rw-each']);
|
|
16
|
-
const COMPONENT_DIRECTIVES = new Set(['data-rw-component', 'data-rw-state', 'rw-bind', 'rw-click', 'rw-submit', 'rw-status']);
|
|
16
|
+
const COMPONENT_DIRECTIVES = new Set(['data-rw-component', 'data-rw-state', 'rw-bind', 'rw-click', 'rw-submit', 'rw-upload', 'rw-paste', 'rw-status']);
|
|
17
17
|
|
|
18
18
|
function attributes(tag, nameEnd, tracked = DIRECTIVES) {
|
|
19
19
|
const found = new Map();
|