redweb 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +289 -276
  3. package/contract.d.ts +11 -3
  4. package/docs/CLI.md +1 -1
  5. package/docs/CLIENT_DEVELOPMENT.md +9 -5
  6. package/docs/CONNECTED_CLIENTS_VERIFICATION.md +65 -0
  7. package/docs/DEVELOPMENT.md +1 -1
  8. package/docs/GETTING_STARTED.md +1 -1
  9. package/docs/LIVE_HTML.md +2 -2
  10. package/docs/MIGRATION.md +1 -1
  11. package/docs/RELEASE_TRUST.md +29 -6
  12. package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
  13. package/docs/SOCKET_CONTRACTS.md +6 -3
  14. package/docs/SOCKET_PAGES.md +172 -0
  15. package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -0
  16. package/docs/SOCKET_PAGE_VERIFICATION.md +85 -0
  17. package/docs/generated.json +2217 -2208
  18. package/docs/releases/0.15.0.json +2217 -0
  19. package/docs/releases/0.16.0.json +2217 -0
  20. package/docs/topics.json +2 -1
  21. package/index.d.ts +57 -6
  22. package/index.js +7 -2
  23. package/package.json +6 -3
  24. package/recipes/shared/README.md +7 -0
  25. package/src/cli/templates.js +3 -2
  26. package/src/htmx/Jsx.js +2 -2
  27. package/src/htmx/LiveHtmlServer.js +1 -1
  28. package/src/htmx/PageManager.js +10 -7
  29. package/src/htmx/PageSocketRoute.js +132 -0
  30. package/src/htmx/ReactiveRenderer.js +8 -2
  31. package/src/htmx/SocketAction.js +19 -0
  32. package/src/htmx/metadata.js +6 -2
  33. package/src/ws/BaseHandler.js +6 -4
  34. package/src/ws/ConnectedClients.js +207 -0
  35. package/src/ws/HandlerGuard.js +4 -0
  36. package/src/ws/RoomRegistry.js +4 -2
  37. package/src/ws/RouteRuntime.js +11 -7
  38. package/src/ws/SocketAction.js +16 -0
  39. package/src/ws/SocketContract.js +3 -2
  40. package/src/ws/SocketRoute.js +7 -5
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.14.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.2.0",
162
+ "redweb-client": "^0.3.0",
163
163
  "ws": "^8.21.3"
164
164
  },
165
- "devDependencies": {
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",
@@ -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.
@@ -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
- devDependencies: {
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,
@@ -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 => LivePage.withRenderContext(context, callback);
269
- const markup = await lifetime.wait(() => renderer ? renderer.initialize(render, withContext) : withContext(render));
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
- 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 });
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) };
@@ -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 }),
@@ -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
- 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);
27
29
  }
28
30
 
29
31
  validateMessage() {