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
package/docs/topics.json
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
{ "id": "runtime-diagnostics", "title": "Understand runtime failures before retrying", "summary": "Safe authentication, authorization, validation and application errors, with actual retry and cancellation guarantees.", "source": "docs/RUNTIME_DIAGNOSTICS.md" },
|
|
13
13
|
{ "id": "agent-access", "title": "Optional read-only agent documentation access", "summary": "Configure local MCP search and exact recipe retrieval without adding dependencies to Redweb servers.", "source": "docs/AGENT_ACCESS.md" },
|
|
14
14
|
{ "id": "live-html", "title": "Pages, components, state, actions, and CSS", "summary": "Server-side TSX, automatic updates, keyed lists, lifecycle, and static export.", "source": "docs/LIVE_HTML.md" },
|
|
15
|
-
{ "id": "socket-contracts", "title": "Typed socket routes and handlers", "summary": "Shared validation, client/server types, protocol errors, and join/move/resume.", "source": "docs/SOCKET_CONTRACTS.md" },
|
|
15
|
+
{ "id": "socket-contracts", "title": "Typed socket routes and handlers", "summary": "Shared validation, client/server types, protocol errors, and join/move/resume.", "source": "docs/SOCKET_CONTRACTS.md" },
|
|
16
|
+
{ "id": "socket-pages", "title": "Server-side TSX for custom socket routes", "summary": "Typed handler bindings, private page state and one generated client connection without handwritten browser glue.", "source": "docs/SOCKET_PAGES.md" },
|
|
16
17
|
{ "id": "room-authorization", "title": "Private rooms and shared request identity", "summary": "Explicit entry policies, bounded authorization, trusted context, publication and revocation.", "source": "docs/ROOM_AUTHORIZATION.md" },
|
|
17
18
|
{ "id": "operations", "title": "Deploy and operate socket services", "summary": "Readiness, shutdown, capacity, reconnection, and distributed boundaries.", "source": "docs/MULTIPLAYER_OPERATIONS.md" },
|
|
18
19
|
{ "id": "production-contract", "title": "Production guarantees and limits", "summary": "Resource ownership, delivery semantics, compatibility, and release gates.", "source": "docs/PRODUCTION_READINESS.md" },
|
package/index.d.ts
CHANGED
|
@@ -35,7 +35,9 @@ declare module 'redweb' {
|
|
|
35
35
|
isAssigned: boolean;
|
|
36
36
|
sendJson(data: unknown): boolean;
|
|
37
37
|
broadcast(data: unknown): number;
|
|
38
|
-
context?: RedWebConnectionContext;
|
|
38
|
+
context?: RedWebConnectionContext;
|
|
39
|
+
/** Present on an attached page connection; checks the actual page class and connection lifetime. */
|
|
40
|
+
page?<Page extends object>(PageClass: new () => Page): Page;
|
|
39
41
|
joinRoom?(roomId: string): boolean;
|
|
40
42
|
/** Bounded permission check followed by atomic membership insertion. */
|
|
41
43
|
enterRoom?(roomId: string): Promise<boolean>;
|
|
@@ -49,13 +51,57 @@ declare module 'redweb' {
|
|
|
49
51
|
sendBinaryEvent?(value: unknown): Promise<boolean>;
|
|
50
52
|
};
|
|
51
53
|
|
|
52
|
-
export interface RedWebConnectionContext extends RequestContext {
|
|
54
|
+
export interface RedWebConnectionContext extends RequestContext {
|
|
53
55
|
readonly connectionId: string;
|
|
54
56
|
readonly principal: unknown;
|
|
55
57
|
session: unknown | null;
|
|
56
58
|
metadata: Record<string, unknown>;
|
|
57
59
|
readonly protocol?: Readonly<{ version: string }>;
|
|
58
|
-
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A deliberate, safe command rejection, unlike an unexpected application error. */
|
|
63
|
+
export class ClientError extends Error { constructor(message: string); }
|
|
64
|
+
export interface ClientEvent { type: string; payload: unknown; }
|
|
65
|
+
export interface ConnectedClientsOptions<Page extends object, Identity> {
|
|
66
|
+
/** Must still equal the admitted principal. Returning another identity never transfers a connection. */
|
|
67
|
+
identity(context: RedWebConnectionContext): Identity | undefined | false | Promise<Identity | undefined | false>;
|
|
68
|
+
page(): new () => Page;
|
|
69
|
+
project(client: ConnectedClient<Page, Identity>, room: string, online: readonly Identity[]): Partial<Page> | Promise<Partial<Page>>;
|
|
70
|
+
authorizationTimeoutMs?: number;
|
|
71
|
+
projectionTimeoutMs?: number;
|
|
72
|
+
/** Explicitly classify domain errors safe to display; all other failures remain private. */
|
|
73
|
+
reject?(error: unknown): string | undefined;
|
|
74
|
+
errorState?(message: string): Partial<Page>;
|
|
75
|
+
/** Optional non-HTML client support. Redweb performs the final checked send. */
|
|
76
|
+
raw?: {
|
|
77
|
+
update(state: Partial<Page>): ClientEvent | Promise<ClientEvent>;
|
|
78
|
+
reject?(message: string): ClientEvent | Promise<ClientEvent>;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export class ConnectedClient<Page extends object, Identity = unknown> {
|
|
82
|
+
private constructor();
|
|
83
|
+
readonly socket: RedWebSocket;
|
|
84
|
+
readonly identity: Identity;
|
|
85
|
+
readonly page: Page;
|
|
86
|
+
readonly rooms: readonly string[];
|
|
87
|
+
/** Requires exactly one current room; use rooms for multi-room applications. */
|
|
88
|
+
readonly room: string;
|
|
89
|
+
/** Reserve room capacity, run a synchronous domain commit, and roll back new membership if it rejects. */
|
|
90
|
+
join(room: string, commit?: () => unknown): Promise<void>;
|
|
91
|
+
leave(room: string): boolean;
|
|
92
|
+
}
|
|
93
|
+
export class ConnectedClients<Page extends object, Identity = unknown> {
|
|
94
|
+
constructor(options: ConnectedClientsOptions<Page, Identity>);
|
|
95
|
+
get(socket: RedWebSocket): ConnectedClient<Page, Identity>;
|
|
96
|
+
refresh(room: string): Promise<unknown>;
|
|
97
|
+
bind<Schemas extends import('redweb/contract').SocketSchemas>(contract: import('redweb/contract').SocketContract<Schemas>): {
|
|
98
|
+
readonly protocol: { readonly versions: readonly string[] };
|
|
99
|
+
handler<Type extends keyof Schemas & string>(type: Type, callback: (
|
|
100
|
+
client: ConnectedClient<Page, Identity>, payload: import('redweb/contract').ContractOutput<Schemas[Type]>,
|
|
101
|
+
) => unknown): import('redweb/contract').SocketHandler<import('redweb/contract').ContractInput<Schemas[Type]>>;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function connectedClients<Page extends object, Identity>(options: ConnectedClientsOptions<Page, Identity>): ConnectedClients<Page, Identity>;
|
|
59
105
|
|
|
60
106
|
export interface AdmissionContext {
|
|
61
107
|
signal: AbortSignal;
|
|
@@ -282,7 +328,9 @@ declare module 'redweb' {
|
|
|
282
328
|
|
|
283
329
|
/** ─────────────────── ROUTES & HANDLERS ─────────────────── */
|
|
284
330
|
|
|
285
|
-
export interface SocketRouteConfig {
|
|
331
|
+
export interface SocketRouteConfig {
|
|
332
|
+
/** Opt-in server-side clients, with RoomRegistry membership and private page projection. */
|
|
333
|
+
connections?: ConnectedClients<any, any>;
|
|
286
334
|
path: string;
|
|
287
335
|
handlers: Array<new () => BaseHandler>;
|
|
288
336
|
services?: Array<new () => SocketService>;
|
|
@@ -412,7 +460,8 @@ declare module 'redweb' {
|
|
|
412
460
|
enter(roomId: string, socket: RedWebSocket): Promise<boolean>;
|
|
413
461
|
leave(roomId: string, socket: RedWebSocket): boolean;
|
|
414
462
|
leaveAll(socket: RedWebSocket): number;
|
|
415
|
-
members(roomId: string): RedWebSocket[];
|
|
463
|
+
members(roomId: string): RedWebSocket[];
|
|
464
|
+
roomsFor(socket: RedWebSocket): string[];
|
|
416
465
|
has(roomId: string, socket: RedWebSocket): boolean;
|
|
417
466
|
broadcast(roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
|
|
418
467
|
broadcastFrom(socket: RedWebSocket, roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
|
|
@@ -554,7 +603,9 @@ declare module 'redweb' {
|
|
|
554
603
|
dispose(): Promise<boolean>;
|
|
555
604
|
}
|
|
556
605
|
|
|
557
|
-
export type PageOptions = {
|
|
606
|
+
export type PageOptions = {
|
|
607
|
+
/** Use a registered custom route for this private live page's typed TSX commands and rendering. */
|
|
608
|
+
socket?: new () => SocketRoute;
|
|
558
609
|
template?: string;
|
|
559
610
|
css?: string | readonly string[];
|
|
560
611
|
live?: boolean;
|
package/index.js
CHANGED
|
@@ -15,13 +15,18 @@ const {
|
|
|
15
15
|
} = require('./src/ws');
|
|
16
16
|
const { BaseHandler } = require('./src/ws/BaseHandler');
|
|
17
17
|
const { defineSocketContract } = require('./contract');
|
|
18
|
-
const { Application, defineApp } = require('./src/Application');
|
|
18
|
+
const { Application, defineApp } = require('./src/Application');
|
|
19
|
+
const { connectedClients, ConnectedClients, ConnectedClient, ClientError } = require('./src/ws/ConnectedClients');
|
|
19
20
|
const HttpServer = require('./src/http/HttpServer');
|
|
20
21
|
const HttpsServer = require('./src/http/HttpsServer');
|
|
21
22
|
const { action, attribute, codeBlock, component, defineSite, each, exportStatic, html, HtmlRenderer, LiveHtmlServer, LivePage, page, start, state, url, view } = require('./src/htmx');
|
|
22
23
|
module.exports = {
|
|
23
24
|
Application,
|
|
24
|
-
defineApp,
|
|
25
|
+
defineApp,
|
|
26
|
+
connectedClients,
|
|
27
|
+
ConnectedClients,
|
|
28
|
+
ConnectedClient,
|
|
29
|
+
ClientError,
|
|
25
30
|
HttpServer,
|
|
26
31
|
HttpsServer,
|
|
27
32
|
BaseHttpServer,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "A small Node.js foundation for HTTP, WebSockets, multiplayer services, and server-rendered HTML",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -159,10 +159,13 @@
|
|
|
159
159
|
"@types/ws": "^8.18.1",
|
|
160
160
|
"cors": "^2.8.5",
|
|
161
161
|
"express": "^4.22.2",
|
|
162
|
-
"redweb-client": "^0.
|
|
162
|
+
"redweb-client": "^0.3.0",
|
|
163
163
|
"ws": "^8.21.3"
|
|
164
164
|
},
|
|
165
|
-
"
|
|
165
|
+
"overrides": {
|
|
166
|
+
"express": { "qs": "6.16.0" }
|
|
167
|
+
},
|
|
168
|
+
"devDependencies": {
|
|
166
169
|
"@types/jest": "^29.5.12",
|
|
167
170
|
"c8": "^10.1.3",
|
|
168
171
|
"expect": "29.7.0",
|
package/recipes/shared/README.md
CHANGED
|
@@ -40,3 +40,10 @@ and application-specific rate limits. These starters are demonstrations, not a h
|
|
|
40
40
|
Never commit secrets; `.env` is ignored but is not loaded automatically.
|
|
41
41
|
|
|
42
42
|
`npx --no-install redweb doctor --json` reports configuration problems without changing your files.
|
|
43
|
+
## Dependency security
|
|
44
|
+
|
|
45
|
+
This starter includes an application-root npm override for Express 4's `qs`
|
|
46
|
+
dependency, selecting patched `qs@6.16.0`. Keep the override when merging this
|
|
47
|
+
starter into an existing application, refresh its lockfile and run `npm audit`.
|
|
48
|
+
Overrides in Redweb's own package do not apply to installed consumers. Recheck
|
|
49
|
+
upstream Express/body-parser releases before removing this temporary mitigation.
|
package/src/cli/templates.js
CHANGED
|
@@ -8,7 +8,7 @@ const TEMPLATES = Object.freeze(['realtime', 'chat', 'site', 'socket', 'dashboar
|
|
|
8
8
|
|
|
9
9
|
function projectFiles(version, template = 'realtime', root = path.resolve(__dirname, '../..')) {
|
|
10
10
|
if (!TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
|
|
11
|
-
const { devDependencies, dependencies } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
11
|
+
const { devDependencies, dependencies, overrides } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
12
12
|
const read = relative => fs.readFileSync(path.join(root, 'recipes', relative), 'utf8');
|
|
13
13
|
const manifest = {
|
|
14
14
|
name: 'redweb-app', private: true, version: '0.0.0',
|
|
@@ -24,7 +24,8 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
|
|
|
24
24
|
...(['chat', 'socket', 'dashboard'].includes(template) ? { zod: devDependencies.zod } : {}),
|
|
25
25
|
...(template === 'dashboard' ? { express: dependencies.express } : {}),
|
|
26
26
|
},
|
|
27
|
-
|
|
27
|
+
overrides,
|
|
28
|
+
devDependencies: {
|
|
28
29
|
typescript: devDependencies.typescript, nodemon: devDependencies.nodemon, ws: dependencies.ws, c8: devDependencies.c8,
|
|
29
30
|
...(template === 'dashboard' ? {
|
|
30
31
|
'@types/node': devDependencies['redweb-dashboard-types'].replace('npm:@types/node@', ''),
|
package/src/htmx/Jsx.js
CHANGED
|
@@ -83,13 +83,13 @@ function renderComponent(Component, properties) {
|
|
|
83
83
|
|
|
84
84
|
function createElement(type, properties, key) {
|
|
85
85
|
const reactive = ReactiveRenderer.jsx();
|
|
86
|
-
const props = properties == null ? {} : properties;
|
|
86
|
+
const props = properties == null ? {} : properties;
|
|
87
87
|
if (!props || typeof props !== 'object' || Array.isArray(props)) {
|
|
88
88
|
throw new TypeError('JSX properties must be an object.');
|
|
89
89
|
}
|
|
90
90
|
let result;
|
|
91
91
|
if (type === Fragment) result = trustedHtml(renderChild(props.children));
|
|
92
|
-
else if (typeof type === 'string') result = renderIntrinsic(type, props);
|
|
92
|
+
else if (typeof type === 'string') result = renderIntrinsic(type, require('./SocketAction').attributes(props));
|
|
93
93
|
else if (typeof type === 'function') result = renderComponent(type, props);
|
|
94
94
|
else throw new TypeError('JSX element types must be intrinsic names or function components.');
|
|
95
95
|
const elementKey = key ?? props.key;
|
|
@@ -71,7 +71,7 @@ class LiveHtmlServer {
|
|
|
71
71
|
if (this.manager.hasLivePages || socketRoutes.length) {
|
|
72
72
|
this.sockets = new SocketServer({
|
|
73
73
|
server: this.http.server,
|
|
74
|
-
routes: [...(this.manager.hasLivePages ? [this.manager.route()] : []), ...socketRoutes],
|
|
74
|
+
routes: [...(this.manager.hasLivePages ? [this.manager.route()] : []), ...require('./PageSocketRoute').bindRoutes(this.manager, socketRoutes)],
|
|
75
75
|
listen,
|
|
76
76
|
port: this.http.port,
|
|
77
77
|
bind: this.http.bind,
|
package/src/htmx/PageManager.js
CHANGED
|
@@ -265,8 +265,10 @@ class PageManager {
|
|
|
265
265
|
if (!isHtml(result)) throw new TypeError('Page layouts must return html.');
|
|
266
266
|
return renderValue(result);
|
|
267
267
|
};
|
|
268
|
-
const withContext = callback =>
|
|
269
|
-
|
|
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.');
|
|
270
272
|
const document = this.createDocument(record, request);
|
|
271
273
|
if (record.metadata.live === false) {
|
|
272
274
|
const result = document(markup, null);
|
|
@@ -277,7 +279,7 @@ class PageManager {
|
|
|
277
279
|
session.renderLifetime = renderer;
|
|
278
280
|
const config = {
|
|
279
281
|
pageId: session.id,
|
|
280
|
-
socketPath: this.paths.socket,
|
|
282
|
+
socketPath: record.socketPath || this.paths.socket,
|
|
281
283
|
runtimePath: this.paths.runtime,
|
|
282
284
|
version: PROTOCOL_VERSION,
|
|
283
285
|
};
|
|
@@ -322,7 +324,7 @@ class PageManager {
|
|
|
322
324
|
session.timer.unref?.();
|
|
323
325
|
}
|
|
324
326
|
|
|
325
|
-
async authenticate(request) {
|
|
327
|
+
async authenticate(request, RouteClass) {
|
|
326
328
|
let id;
|
|
327
329
|
try {
|
|
328
330
|
id = new URL(request.url, `http://${request.headers.host || 'localhost'}`).searchParams.get('pageId');
|
|
@@ -330,8 +332,9 @@ class PageManager {
|
|
|
330
332
|
return false;
|
|
331
333
|
}
|
|
332
334
|
if (typeof id !== 'string' || id.length > 128) return false;
|
|
333
|
-
const session = this.pending.get(id) || this.active.get(id);
|
|
334
|
-
if (!session || session.socket || session.detaching) 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;
|
|
335
338
|
try {
|
|
336
339
|
const principal = await this.identity.resolve(request, session.lifetime.signal);
|
|
337
340
|
if (!Object.is(principal, session.principal)) return false;
|
|
@@ -385,7 +388,7 @@ class PageManager {
|
|
|
385
388
|
connectionContext(session, socket) { return Object.freeze({ ...session.context, socket, signal: session.connection.signal, principal: session.principal }); }
|
|
386
389
|
|
|
387
390
|
checkConnected(session, socket) {
|
|
388
|
-
if (!this.available(session) || session.socket !== socket) throw new AccessDenied('ACCESS_CANCELLED');
|
|
391
|
+
if (!this.available(session) || session.socket !== socket || (socket.readyState !== undefined && socket.readyState !== 1)) throw new AccessDenied('ACCESS_CANCELLED');
|
|
389
392
|
session.connection.check();
|
|
390
393
|
}
|
|
391
394
|
|
|
@@ -0,0 +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 };
|
|
@@ -222,8 +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
|
-
|
|
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 });
|
|
227
233
|
}
|
|
228
234
|
}
|
|
229
235
|
|
|
@@ -0,0 +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) };
|
package/src/htmx/metadata.js
CHANGED
|
@@ -178,7 +178,10 @@ function page(routePath, options = {}) {
|
|
|
178
178
|
if (!['connection', 'shared'].includes(scope)) {
|
|
179
179
|
throw new TypeError('Page scope must be "connection" or "shared".');
|
|
180
180
|
}
|
|
181
|
-
if (typeof live !== 'boolean') throw new TypeError('Page live must be a boolean.');
|
|
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
|
+
}
|
|
182
185
|
if (layout !== undefined && typeof layout !== 'function') throw new TypeError('Page layout must be a function.');
|
|
183
186
|
const head = pageHead(options.head);
|
|
184
187
|
const cache = pageCache(options.cache, live);
|
|
@@ -189,7 +192,8 @@ function page(routePath, options = {}) {
|
|
|
189
192
|
PAGE_METADATA.set(PageClass, Object.freeze({
|
|
190
193
|
path: routePath,
|
|
191
194
|
template,
|
|
192
|
-
scope,
|
|
195
|
+
scope,
|
|
196
|
+
...(options.socket && { socket: options.socket }),
|
|
193
197
|
...(live === false && { live: false }),
|
|
194
198
|
...(head && { head }),
|
|
195
199
|
...(cache && { cache }),
|
package/src/ws/BaseHandler.js
CHANGED
|
@@ -20,10 +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
|
-
|
|
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);
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
validateMessage() {
|