redweb 0.15.0 → 0.16.1

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.
@@ -0,0 +1,24 @@
1
+ import { action, component, defineApp, page, state } from 'redweb';
2
+
3
+ @component()
4
+ class Counter {
5
+ @state() count = 0;
6
+
7
+ @action()
8
+ increment() { this.count += 1; }
9
+
10
+ render() {
11
+ return <button rw-click="increment">Count {this.count}</button>;
12
+ }
13
+ }
14
+
15
+ @page('/')
16
+ class CountersPage {
17
+ first = new Counter();
18
+ second = new Counter();
19
+
20
+ render() { return <main>{this.first}{this.second}</main>; }
21
+ }
22
+
23
+ const app = defineApp({ pages: [CountersPage] });
24
+ app.run();
@@ -0,0 +1,16 @@
1
+ import { action, defineApp, page, state } from 'redweb';
2
+
3
+ @page('/', { shared: true })
4
+ class CounterPage {
5
+ @state() count = 0;
6
+
7
+ @action()
8
+ increment() { this.count += 1; }
9
+
10
+ render() {
11
+ return <button rw-click="increment">Count {this.count}</button>;
12
+ }
13
+ }
14
+
15
+ const app = defineApp({ pages: [CounterPage] });
16
+ app.run();
@@ -1,8 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { page, start, BaseHandler, SocketRoute, RedWebSocket, RedWebRequest, LivePageRequestContext } from 'redweb';
2
+ import { page, defineApp, BaseHandler, SocketRoute, RedWebSocket, RedWebRequest, LivePageRequestContext } from 'redweb';
3
3
 
4
4
  // A runnable local demonstration, not a production credential store.
5
- export function createApp(port = 8181) {
5
+ export async function createApp(port = 8181) {
6
6
  const token = randomBytes(32).toString('base64url');
7
7
  let enabled = true;
8
8
  const authenticate = (request: Pick<RedWebRequest, 'headers'>) =>
@@ -28,9 +28,9 @@ export function createApp(port = 8181) {
28
28
  }
29
29
  }
30
30
 
31
- const app = start(Home, { listen: false, authenticate, logger: null });
32
- const team = app.sockets!.addRoute(Team);
33
- app.server.listen(port, '127.0.0.1');
31
+ const app = defineApp({ pages: [Home], sockets: [Team], authenticate, port, bind: '127.0.0.1', logger: null });
32
+ await app.run();
33
+ const team = app.sockets!.routes.find(route => route instanceof Team)!;
34
34
  return {
35
35
  app, team, token,
36
36
  async revoke() {
@@ -42,10 +42,9 @@ export function createApp(port = 8181) {
42
42
  };
43
43
  }
44
44
 
45
- if (require.main === module) {
46
- const demo = createApp();
47
- console.log('Local demo: http://127.0.0.1:8181/ and ws://127.0.0.1:8181/team');
48
- console.log(`Authorization: Bearer ${demo.token}`); // One fresh local-demo credential per run.
49
- process.once('SIGTERM', () => void demo.shutdown().catch(console.error));
50
- process.once('SIGINT', () => void demo.shutdown().catch(console.error));
51
- }
45
+ if (require.main === module) {
46
+ createApp().then(demo => {
47
+ console.log('Local demo: http://127.0.0.1:8181/ and ws://127.0.0.1:8181/team');
48
+ console.log(`Authorization: Bearer ${demo.token}`); // One fresh local-demo credential per run.
49
+ });
50
+ }
@@ -0,0 +1,2 @@
1
+ body { max-width: 50rem; margin: 3rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; }
2
+ nav { display: flex; gap: 1rem; margin-bottom: 2rem; }
@@ -0,0 +1,22 @@
1
+ import { defineApp, defineSite } from 'redweb';
2
+
3
+ const site = defineSite({
4
+ css: 'site.css',
5
+ layout: content => <body>
6
+ <nav><a href="/">Home</a> · <a href="/about">About</a></nav>
7
+ <main>{content}</main>
8
+ </body>,
9
+ });
10
+
11
+ @site.page('/', { head: { title: 'Home' } })
12
+ class HomePage {
13
+ render() { return <h1>Welcome to Redweb</h1>; }
14
+ }
15
+
16
+ @site.page('/about', { head: { title: 'About' } })
17
+ class AboutPage {
18
+ render() { return <p>Two pages, one layout, no browser framework.</p>; }
19
+ }
20
+
21
+ const app = defineApp({ pages: [HomePage, AboutPage] });
22
+ app.run();
package/docs/topics.json CHANGED
@@ -2,7 +2,7 @@
2
2
  { "id": "getting-started", "title": "Choose a starter and build a working app", "summary": "Requirements, fit, development, tests, and production boundaries.", "source": "docs/GETTING_STARTED.md" },
3
3
  { "id": "application", "title": "One application, one listener", "summary": "Define pages, socket routes and application services together; run and shut down one owned HTTP/WebSocket listener.", "source": "docs/APPLICATION.md" },
4
4
  { "id": "guides/realtime-dashboard", "title": "Build a private realtime dashboard", "summary": "Persistent SQLite cards, account-private updates and sign-out across tabs, with explicit single-process limits.", "source": "docs/guides/realtime-dashboard.md", "recipe": { "template": "dashboard", "file": "src/cards.tsx" } },
5
- { "id": "guides/jsx-without-react", "title": "Render JSX without React", "summary": "TypeScript pages, a shared layout and external CSS, rendered on the server without browser framework code.", "source": "docs/guides/jsx-without-react.md", "recipe": { "template": "site", "file": "src/app.tsx" } },
5
+ { "id": "guides/jsx-without-react", "title": "Render JSX without React", "summary": "TypeScript pages, a shared layout and external CSS, rendered on the server without browser framework code.", "source": "docs/guides/jsx-without-react.md", "codeSource": "docs/snippets/site.tsx", "recipe": { "template": "site", "file": "src/app.tsx" } },
6
6
  { "id": "guides/chatroom", "title": "Build a chatroom with live presence", "summary": "Reusable server-side components, validated forms and disconnect-aware presence, without custom browser socket glue.", "source": "docs/guides/chatroom.md", "recipe": { "template": "chat", "file": "src/chatroom.tsx" } },
7
7
  { "id": "guides/typed-websockets", "title": "Share typed WebSocket contracts", "summary": "One match route, separate join/move/resume handlers and validated client/server payloads from the same schema.", "source": "docs/guides/typed-websockets.md", "recipe": { "template": "socket", "file": "src/handlers.ts" } },
8
8
  { "id": "guides/http-websocket", "title": "Serve HTTP and WebSockets on one port", "summary": "An Express endpoint and raw socket route share one listener with one explicit shutdown owner.", "source": "docs/guides/http-websocket.md", "recipe": { "template": "http-ws", "file": "src/app.tsx" } },
package/index.d.ts CHANGED
@@ -51,13 +51,57 @@ declare module 'redweb' {
51
51
  sendBinaryEvent?(value: unknown): Promise<boolean>;
52
52
  };
53
53
 
54
- export interface RedWebConnectionContext extends RequestContext {
54
+ export interface RedWebConnectionContext extends RequestContext {
55
55
  readonly connectionId: string;
56
56
  readonly principal: unknown;
57
57
  session: unknown | null;
58
58
  metadata: Record<string, unknown>;
59
59
  readonly protocol?: Readonly<{ version: string }>;
60
- }
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>;
61
105
 
62
106
  export interface AdmissionContext {
63
107
  signal: AbortSignal;
@@ -284,7 +328,9 @@ declare module 'redweb' {
284
328
 
285
329
  /** ─────────────────── ROUTES & HANDLERS ─────────────────── */
286
330
 
287
- 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>;
288
334
  path: string;
289
335
  handlers: Array<new () => BaseHandler>;
290
336
  services?: Array<new () => SocketService>;
@@ -414,7 +460,8 @@ declare module 'redweb' {
414
460
  enter(roomId: string, socket: RedWebSocket): Promise<boolean>;
415
461
  leave(roomId: string, socket: RedWebSocket): boolean;
416
462
  leaveAll(socket: RedWebSocket): number;
417
- members(roomId: string): RedWebSocket[];
463
+ members(roomId: string): RedWebSocket[];
464
+ roomsFor(socket: RedWebSocket): string[];
418
465
  has(roomId: string, socket: RedWebSocket): boolean;
419
466
  broadcast(roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
420
467
  broadcastFrom(socket: RedWebSocket, roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
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.15.0",
3
+ "version": "0.16.1",
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",
@@ -104,21 +104,28 @@ class Documentation {
104
104
  return { id: `recipes/${template}`, title: `${template[0].toUpperCase()}${template.slice(1)} starter`, summary: explanation.split('\n').find(line => line && !line.startsWith('#')), source, markdown, files };
105
105
  }
106
106
 
107
- recipeCode(entry) {
107
+ recipeCode(entry) {
108
108
  const files = projectFiles(this.manifest.version, entry.template, this.root);
109
109
  const file = files.find(file => file.path === entry.file);
110
110
  if (!file) throw new Error(`Unknown documentation recipe file: ${entry.template}/${entry.file}`);
111
- return normalize(file.content);
112
- }
111
+ return normalize(file.content);
112
+ }
113
+
114
+ code(entry, field) {
115
+ return entry.recipe ? this.recipeCode(entry.recipe) : entry.codeSource ? this.read(entry.codeSource) : entry[field];
116
+ }
113
117
 
114
118
  topic(topic) {
115
- let markdown = `${this.notice()}\n\n${this.links(this.read(topic.source), topic.source)}`;
119
+ let markdown = `${this.notice()}\n\n${this.links(this.read(topic.source), topic.source)}`;
120
+ markdown = markdown.replace(/<!-- source: ([\w./-]+) -->/g,
121
+ (_match, file) => fence(this.read(file), language(file)));
116
122
  if (topic.recipe) {
117
123
  const { template, file } = topic.recipe;
118
124
  markdown += [
119
125
  '\n## Build and run the complete application', this.setup(template),
120
- `The [complete ${template} recipe](${this.basePath}/recipes/${template}.md) contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.`,
121
- `## Source walkthrough: ${file}`, fence(this.recipeCode(topic.recipe), language(file)),
126
+ `The [complete ${template} recipe](${this.basePath}/recipes/${template}.md) contains every generated file, its real acceptance tests, and deployment instructions.`,
127
+ ...(topic.codeSource ? [] : [`The source below is one of those files, not a standalone program; initialize the whole project before modifying it.`,
128
+ `## Source walkthrough: ${file}`, fence(this.recipeCode(topic.recipe), language(file))]),
122
129
  ].join('\n\n') + '\n';
123
130
  }
124
131
  return { ...topic, markdown };
@@ -128,8 +135,8 @@ class Documentation {
128
135
  const pages = this.topics.map(topic => this.topic(topic));
129
136
  pages.push(...TEMPLATES.map(template => this.recipe(template)));
130
137
  const reference = this.reference;
131
- const api = reference.api.map(section => ({ ...section, usage: section.recipe ? this.recipeCode(section.recipe) : section.usage }));
132
- const examples = reference.examples.map(example => ({ ...example, code: example.recipe ? this.recipeCode(example.recipe) : example.codeSource ? this.read(example.codeSource) : example.code }));
138
+ const api = reference.api.map(section => ({ ...section, usage: this.code(section, 'usage') }));
139
+ const examples = reference.examples.map(example => ({ ...example, code: this.code(example, 'code') }));
133
140
  for (const example of examples) {
134
141
  pages.push({ id: `examples/${example.id}`, title: example.title, summary: example.summary, source: 'docs/reference.json', markdown: [
135
142
  `# ${example.title}`, this.notice(), example.summary,
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ const { AccessPolicy, AccessDenied } = require('../access/AccessPolicy');
4
+ const { guards } = require('./HandlerGuard');
5
+ const { BoundedOperation } = require('../async/BoundedOperation');
6
+
7
+ /** Deliberately public rejection text; unexpected exceptions remain private. */
8
+ class ClientError extends Error {
9
+ constructor(message) {
10
+ if (typeof message !== 'string' || !message || message.length > 512) throw new TypeError('Client error text must contain 1–512 characters.');
11
+ super(message);
12
+ }
13
+ }
14
+
15
+ class ConnectedClient {
16
+ #owner;
17
+ constructor(owner, socket) {
18
+ this.#owner = owner;
19
+ Object.defineProperty(this, 'socket', { value: socket });
20
+ Object.freeze(this);
21
+ }
22
+ get identity() { this.#owner.assertActive(this.socket); return this.socket.context.principal; }
23
+ get rooms() { return this.#owner.rooms.roomsFor(this.socket); }
24
+ get room() {
25
+ const rooms = this.rooms;
26
+ if (rooms.length !== 1) throw new ClientError('Join a room first.');
27
+ return rooms[0];
28
+ }
29
+ get page() { this.#owner.assertActive(this.socket); return this.socket.page(this.#owner.options.page()); }
30
+ join(room, commit) { return this.#owner.join(this, room, commit); }
31
+ leave(room) { this.#owner.assertActive(this.socket); return this.#owner.rooms.leave(room, this.socket); }
32
+ }
33
+
34
+ /** One route-owned projection layer; RoomRegistry alone owns membership. */
35
+ class ConnectedClients {
36
+ #route;
37
+ #clients = new WeakMap();
38
+ #joining = new WeakSet();
39
+ #queued = new Set();
40
+ #generations = new Map();
41
+
42
+ constructor(options) {
43
+ if (!options || typeof options !== 'object') throw new TypeError('Connected clients require options.');
44
+ for (const name of ['identity', 'page', 'project']) {
45
+ if (typeof options[name] !== 'function') throw new TypeError(`Connected clients require ${name}.`);
46
+ }
47
+ for (const name of ['reject', 'errorState']) {
48
+ if (options[name] !== undefined && typeof options[name] !== 'function') throw new TypeError(`${name} must be a function.`);
49
+ }
50
+ if (options.raw !== undefined && (!options.raw || typeof options.raw.update !== 'function' ||
51
+ (options.raw.reject !== undefined && typeof options.raw.reject !== 'function'))) {
52
+ throw new TypeError('A raw adapter requires update and an optional reject callback.');
53
+ }
54
+ this.options = Object.freeze({ ...options, raw: options.raw && Object.freeze({ ...options.raw }) });
55
+ this.policy = new AccessPolicy(async context => {
56
+ const identity = await this.options.identity(context);
57
+ return identity !== undefined && identity !== null && identity !== false && Object.is(identity, context.principal);
58
+ }, options.authorizationTimeoutMs);
59
+ this.projection = new BoundedOperation(options.projectionTimeoutMs);
60
+ }
61
+
62
+ attach(route, rooms) {
63
+ if (this.#route) throw new TypeError('Connected clients belong to one route instance. Create them inside your application factory.');
64
+ if (!rooms || !route.protocolPolicy) throw new TypeError('Connected clients require rooms and a socket protocol.');
65
+ this.#route = route;
66
+ this.rooms = rooms;
67
+ }
68
+
69
+ assertActive(socket) {
70
+ if (!this.#route || this.#route.draining || this.#route.clients.get(socket.clientKey) !== socket ||
71
+ socket.readyState !== 1 || socket.context.signal.aborted) throw new AccessDenied('ACCESS_CANCELLED');
72
+ }
73
+
74
+ get(socket) {
75
+ this.assertActive(socket);
76
+ let client = this.#clients.get(socket);
77
+ if (!client) { client = new ConnectedClient(this, socket); this.#clients.set(socket, client); }
78
+ return client;
79
+ }
80
+
81
+ async check(socket) {
82
+ this.assertActive(socket);
83
+ await this.policy.check(socket.context);
84
+ const guard = guards.get(socket);
85
+ if (guard) await guard();
86
+ this.assertActive(socket);
87
+ if (!socket.page && !this.options.raw) throw new AccessDenied();
88
+ }
89
+
90
+ async join(client, room, commit = () => {}) {
91
+ const socket = client.socket;
92
+ if (typeof commit !== 'function') throw new TypeError('Room commit must be synchronous.');
93
+ if (this.#joining.has(socket)) throw new ClientError('A room join is already pending.');
94
+ const existing = this.rooms.has(room, socket);
95
+ this.#joining.add(socket);
96
+ try {
97
+ await this.check(socket);
98
+ if (!await this.rooms.enter(room, socket)) throw new ClientError('Room capacity or access denied.');
99
+ await this.check(socket);
100
+ if (!this.rooms.has(room, socket)) throw new AccessDenied('ACCESS_CANCELLED');
101
+ const result = commit();
102
+ if (result && typeof result.then === 'function') {
103
+ Promise.resolve(result).catch(() => {});
104
+ throw new TypeError('Room commit must be synchronous.');
105
+ }
106
+ } catch (error) {
107
+ if (!existing) this.rooms.leave(room, socket);
108
+ throw error;
109
+ } finally { this.#joining.delete(socket); this.changed(room); }
110
+ }
111
+
112
+ changed(room) {
113
+ if (this.#route.draining) return;
114
+ this.#generations.set(room, Symbol());
115
+ if (this.#queued.has(room)) return;
116
+ this.#queued.add(room);
117
+ queueMicrotask(() => {
118
+ if (!this.#queued.delete(room)) return;
119
+ void this.refresh(room).catch(error => this.#route.logger.error('Connected client refresh failed:', error));
120
+ });
121
+ }
122
+
123
+ refresh(room) {
124
+ this.rooms.validateRoomId(room);
125
+ this.#queued.delete(room);
126
+ return this.#route.runtime.run(() => this.project(room));
127
+ }
128
+
129
+ async project(room) {
130
+ const generation = Symbol();
131
+ this.#generations.set(room, generation);
132
+ const current = socket => this.#generations.get(room) === generation && this.rooms.has(room, socket) && !this.#joining.has(socket);
133
+ const eligible = [];
134
+ try {
135
+ await Promise.all(this.rooms.members(room).map(async socket => {
136
+ if (!current(socket)) return;
137
+ try {
138
+ await this.check(socket);
139
+ if (current(socket)) { const client = this.get(socket); eligible.push({ client, identity: client.identity }); }
140
+ }
141
+ catch (error) { if (current(socket)) this.exclude(socket, error); }
142
+ }));
143
+ const online = Object.freeze([...new Set(eligible.map(entry => entry.identity))]);
144
+ await Promise.all(eligible.map(async ({ client }) => {
145
+ const socket = client.socket;
146
+ try {
147
+ if (!current(socket)) return;
148
+ const { state, frame } = await this.projection.run(async () => {
149
+ const state = await this.options.project(client, room, online);
150
+ const frame = socket.page ? null : await this.options.raw.update(state);
151
+ return { state, frame };
152
+ }, socket.context.signal);
153
+ await this.check(socket);
154
+ if (!current(socket)) return;
155
+ if (socket.page) Object.assign(client.page, state);
156
+ else socket.sendEvent(frame.type, frame.payload);
157
+ } catch (error) { if (current(socket)) this.exclude(socket, error); }
158
+ }));
159
+ } finally {
160
+ if (this.#generations.get(room) === generation) this.#generations.delete(room);
161
+ }
162
+ }
163
+
164
+ exclude(socket, error) {
165
+ this.rooms.leaveAll(socket);
166
+ if (!(error instanceof AccessDenied)) this.#route.handleError(socket, error);
167
+ socket.close(error instanceof AccessDenied ? 1008 : 1011, 'Connection unavailable.');
168
+ }
169
+
170
+ bind(contract) {
171
+ return Object.freeze({
172
+ protocol: contract.protocol,
173
+ handler: (type, callback) => {
174
+ if (typeof callback !== 'function') throw new TypeError('A client handler requires a callback.');
175
+ return contract.handler(type, async (socket, payload, message) => {
176
+ const client = this.get(socket);
177
+ await this.check(socket);
178
+ try {
179
+ const result = await callback(client, payload);
180
+ if (result === false) return false;
181
+ await Promise.all(client.rooms.map(room => this.refresh(room)));
182
+ if (!socket.page && message.requestId !== undefined) {
183
+ await this.check(socket);
184
+ socket.sendEvent('redweb:result', null, { requestId: message.requestId });
185
+ }
186
+ return result;
187
+ } catch (error) {
188
+ const text = error instanceof ClientError ? error.message : this.options.reject?.(error);
189
+ if (typeof text !== 'string' || !text || text.length > 512) throw error;
190
+ const metadata = { requestId: message.requestId };
191
+ const frame = socket.page || !this.options.raw.reject ? null :
192
+ await this.projection.run(() => this.options.raw.reject(text), socket.context.signal);
193
+ await this.check(socket);
194
+ if (socket.page) {
195
+ if (this.options.errorState) Object.assign(client.page, this.options.errorState(text));
196
+ } else if (frame) socket.sendEvent(frame.type, frame.payload);
197
+ socket.sendProtocolError('COMMAND_REJECTED', text, metadata);
198
+ return false;
199
+ }
200
+ });
201
+ },
202
+ });
203
+ }
204
+ }
205
+
206
+ const connectedClients = options => new ConnectedClients(options);
207
+ module.exports = { connectedClients, ConnectedClients, ConnectedClient, ClientError };
@@ -102,10 +102,12 @@ class RoomRegistry {
102
102
  return roomIds.length;
103
103
  }
104
104
 
105
- members(roomId) {
105
+ members(roomId) {
106
106
  this.validateRoomId(roomId);
107
107
  return [...(this.rooms.get(roomId) || [])];
108
- }
108
+ }
109
+
110
+ roomsFor(socket) { return [...(this.memberships.get(socket) || [])]; }
109
111
 
110
112
  has(roomId, socket) {
111
113
  this.validateRoomId(roomId);
@@ -5,7 +5,8 @@ const HeartbeatMonitor = require('./HeartbeatMonitor');
5
5
  const RoomRegistry = require('./RoomRegistry');
6
6
  const SessionRegistry = require('./SessionRegistry');
7
7
  const DistributionBridge = require('./DistributionBridge');
8
- const requestSnapshot = require('../context/RequestSnapshot');
8
+ const requestSnapshot = require('../context/RequestSnapshot');
9
+ const { ConnectedClients } = require('./ConnectedClients');
9
10
 
10
11
  function joinRoom(roomId) { return this.__redwebRuntimeOwner.rooms.join(roomId, this); }
11
12
  function enterRoom(roomId) { return this.__redwebRuntimeOwner.rooms.enter(roomId, this); }
@@ -23,7 +24,7 @@ function publishEvent(type, payload) { return this.__redwebRuntimeOwner.route.pu
23
24
  function connectionContext() { return this.__redwebRuntimeOwner.ensureContext(this); }
24
25
 
25
26
  class RouteRuntime {
26
- constructor(route, { heartbeat, rooms, sessions, distribution, drainHandlers }) {
27
+ constructor(route, { heartbeat, rooms, sessions, distribution, drainHandlers, connections }) {
27
28
  this.route = route;
28
29
  this.inFlight = drainHandlers ? new Set() : null;
29
30
  this.abortController = drainHandlers ? new AbortController() : null;
@@ -31,7 +32,8 @@ class RouteRuntime {
31
32
  this.contexts = new WeakMap();
32
33
  this.requests = new WeakMap();
33
34
  this.needsContext = Boolean(route.admissionPolicy || route.protocolPolicy || rooms || sessions || drainHandlers);
34
- try {
35
+ try {
36
+ if (connections !== undefined && !(connections instanceof ConnectedClients)) throw new TypeError('connections must be created with connectedClients().');
35
37
  this.heartbeat = heartbeat === undefined ? null : new HeartbeatMonitor(heartbeat, route.logger);
36
38
  this.rooms = rooms === undefined || rooms === false
37
39
  ? null
@@ -40,9 +42,10 @@ class RouteRuntime {
40
42
  socket.readyState === 1 && this.contexts.get(socket)?.active !== false,
41
43
  contextFor: socket => this.ensureContext(socket),
42
44
  policy: route.transportPolicy,
43
- onChange: action => {
45
+ onChange: (action, roomId) => {
44
46
  route.metrics?.increment(`redweb.room.${action}`);
45
- route.metrics?.gauge('redweb.rooms.active', this.rooms.size);
47
+ route.metrics?.gauge('redweb.rooms.active', this.rooms.size);
48
+ connections?.changed(roomId);
46
49
  },
47
50
  });
48
51
  this.sessions = sessions === undefined || sessions === false
@@ -51,9 +54,10 @@ class RouteRuntime {
51
54
  if (distribution !== undefined && distribution !== false && typeof distribution?.onEvent !== 'function') {
52
55
  throw new TypeError('`distribution.onEvent` must be a function.');
53
56
  }
54
- this.distribution = distribution === undefined || distribution === false
57
+ this.distribution = distribution === undefined || distribution === false
55
58
  ? null
56
- : new DistributionBridge(distribution, event => distribution.onEvent(event, route), route.logger);
59
+ : new DistributionBridge(distribution, event => distribution.onEvent(event, route), route.logger);
60
+ connections?.attach(route, this.rooms);
57
61
  } catch (error) {
58
62
  this.heartbeat?.stop();
59
63
  this.sessions?.stop();
@@ -93,11 +93,12 @@ class SocketRoute {
93
93
  limits,
94
94
  orderedMessages = false,
95
95
  heartbeat,
96
- rooms,
96
+ connections,
97
+ rooms = connections ? true : undefined,
97
98
  sessions,
98
99
  metrics,
99
100
  distribution,
100
- drainHandlers = false,
101
+ drainHandlers = Boolean(connections),
101
102
  protocol,
102
103
  maxPendingUpgrades = 64,
103
104
  } = {}) {
@@ -192,7 +193,7 @@ class SocketRoute {
192
193
  throw error;
193
194
  }
194
195
  try {
195
- this.runtime = new RouteRuntime(this, { heartbeat, rooms, sessions, distribution, drainHandlers });
196
+ this.runtime = new RouteRuntime(this, { heartbeat, rooms, sessions, distribution, drainHandlers, connections });
196
197
  Object.assign(this, this.runtime.expose());
197
198
  } catch (error) {
198
199
  this.disposeServices();