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.
Files changed (67) hide show
  1. package/CHANGELOG.md +35 -22
  2. package/README.md +293 -289
  3. package/contract.d.ts +11 -11
  4. package/docs/API_EXAMPLES_VERIFICATION.md +22 -22
  5. package/docs/APPLICATION.md +96 -94
  6. package/docs/CLI.md +122 -116
  7. package/docs/CLIENT_DEVELOPMENT.md +9 -9
  8. package/docs/CONNECTED_CLIENTS_VERIFICATION.md +65 -65
  9. package/docs/DEVELOPMENT.md +81 -81
  10. package/docs/GETTING_STARTED.md +78 -58
  11. package/docs/LIVE_HTML.md +555 -478
  12. package/docs/MIGRATION.md +28 -28
  13. package/docs/RELEASE_TRUST.md +88 -88
  14. package/docs/RUNTIME_DIAGNOSTICS.md +78 -78
  15. package/docs/SOCKET_CONTRACTS.md +42 -42
  16. package/docs/SOCKET_PAGES.md +172 -172
  17. package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -120
  18. package/docs/SOCKET_PAGE_VERIFICATION.md +85 -85
  19. package/docs/generated.json +2286 -2286
  20. package/docs/guides/chatroom.md +1 -1
  21. package/docs/guides/jsx-without-react.md +14 -14
  22. package/docs/reference.json +1329 -1329
  23. package/docs/releases/0.15.0.json +2217 -2217
  24. package/docs/releases/0.16.0.json +2217 -2217
  25. package/docs/releases/0.16.1.json +2286 -2286
  26. package/docs/releases/0.16.2.json +2286 -0
  27. package/docs/releases/0.16.3.json +2286 -0
  28. package/docs/snippets/components.tsx +24 -24
  29. package/docs/snippets/counter.tsx +16 -16
  30. package/docs/snippets/room-access.tsx +11 -11
  31. package/docs/snippets/site.css +2 -2
  32. package/docs/snippets/site.tsx +22 -22
  33. package/docs/topics.json +3 -3
  34. package/index.d.ts +92 -57
  35. package/index.js +13 -8
  36. package/package.json +8 -8
  37. package/recipes/foundation/README.md +7 -0
  38. package/recipes/foundation/app.test.cjs +15 -0
  39. package/recipes/foundation/app.tsx +12 -0
  40. package/recipes/shared/README.md +7 -7
  41. package/src/Application.js +4 -4
  42. package/src/access/failure-codes.json +4 -0
  43. package/src/cli/ProjectInitializer.js +1 -1
  44. package/src/cli/arguments.js +15 -3
  45. package/src/cli/run.js +10 -1
  46. package/src/cli/templates.js +34 -21
  47. package/src/docs/Documentation.js +25 -15
  48. package/src/htmx/Jsx.js +2 -2
  49. package/src/htmx/LiveHtmlServer.js +5 -1
  50. package/src/htmx/LivePage.js +5 -1
  51. package/src/htmx/LiveResource.js +96 -0
  52. package/src/htmx/PageManager.js +126 -13
  53. package/src/htmx/PageSocketRoute.js +132 -132
  54. package/src/htmx/PageTaskLane.js +39 -0
  55. package/src/htmx/ReactiveRenderer.js +8 -8
  56. package/src/htmx/SocketAction.js +19 -19
  57. package/src/htmx/TemplateRenderer.js +1 -1
  58. package/src/htmx/index.js +3 -2
  59. package/src/htmx/metadata.js +117 -6
  60. package/src/ws/BaseHandler.js +6 -6
  61. package/src/ws/ConnectedClients.js +207 -207
  62. package/src/ws/HandlerGuard.js +4 -4
  63. package/src/ws/RoomRegistry.js +4 -4
  64. package/src/ws/RouteRuntime.js +11 -11
  65. package/src/ws/SocketAction.js +16 -16
  66. package/src/ws/SocketContract.js +3 -3
  67. package/src/ws/SocketRoute.js +7 -7
@@ -1,15 +1,17 @@
1
1
  'use strict';
2
2
 
3
- const { TEMPLATES } = require('./templates');
3
+ const { CAPABILITIES, TEMPLATES } = require('./templates');
4
4
  const { KINDS } = require('./ProjectAddition');
5
5
 
6
6
  const USAGE = [
7
- `Usage: redweb init [directory] [--template ${TEMPLATES.join('|')}] [--existing] [--dry-run] [--json]`,
7
+ `Usage: redweb init [directory] [--with ${CAPABILITIES.join(',')}] [--template ${TEMPLATES.join('|')}] [--bare] [--existing] [--dry-run] [--json]`,
8
8
  ' redweb doctor [directory] [--port number] [--json]',
9
9
  ` redweb add <${KINDS.join('|')}> <name> [directory] [--config file] [--source-dir dir] [--test-dir dir] [--dry-run] [--json]`,
10
10
  ' redweb --help | --version',
11
11
  '',
12
12
  '--existing creates only a missing tsconfig.json; no starter or package changes.',
13
+ '--with adds neutral capability dependencies without generating example-domain code.',
14
+ '--bare omits generated tests; templates remain explicit examples.',
13
15
  '--dry-run reports planned files without writing anything.',
14
16
  'doctor inspects configuration without executing application code or repairing files.',
15
17
  ].join('\n') + '\n';
@@ -38,6 +40,7 @@ function parseArguments(args) {
38
40
  seen.add(value);
39
41
  if (value === '--json') result.json = true;
40
42
  else if (value === '--existing' && command === 'init') result.existing = true;
43
+ else if (value === '--bare' && command === 'init') result.bare = true;
41
44
  else if (value === '--dry-run' && ['init', 'add'].includes(command)) result.dryRun = true;
42
45
  else if (command === 'add' && ['--config', '--source-dir', '--test-dir'].includes(value)) {
43
46
  const argument = rest[++i];
@@ -49,13 +52,22 @@ function parseArguments(args) {
49
52
  if (!TEMPLATES.includes(template)) throw new Error(`--template must be one of: ${TEMPLATES.join(', ')}.`);
50
53
  result.template = template;
51
54
  }
55
+ else if (value === '--with' && command === 'init') {
56
+ const raw = rest[++i];
57
+ if (!raw || raw.startsWith('-')) throw new Error('--with requires a comma-separated capability list.');
58
+ const capabilities = raw.split(',');
59
+ if (capabilities.some(capability => !CAPABILITIES.includes(capability)) || new Set(capabilities).size !== capabilities.length) {
60
+ throw new Error(`--with must contain unique capabilities from: ${CAPABILITIES.join(', ')}.`);
61
+ }
62
+ result.with = capabilities;
63
+ }
52
64
  else if (value === '--port' && command === 'doctor') {
53
65
  const port = rest[++i];
54
66
  if (!/^\d+$/.test(port) || Number(port) > 65535) throw new Error('--port must be an integer from 0 through 65535.');
55
67
  result.port = Number(port);
56
68
  } else throw new Error(`Unknown option for ${command}: ${value}`);
57
69
  }
58
- if (result.existing && result.template) throw new Error('--existing and --template cannot be combined.');
70
+ if (result.existing && (result.template || result.with || result.bare)) throw new Error('--existing cannot be combined with --template, --with, or --bare.');
59
71
  return result;
60
72
  }
61
73
 
package/src/cli/run.js CHANGED
@@ -36,9 +36,18 @@ async function run(args, cwd, version) {
36
36
  return { exitCode: 0, stdout: `${output}\n`, stderr: '' };
37
37
  }
38
38
  const result = new ProjectInitializer(version).initialize(root, options);
39
- const report = { schemaVersion: 1, operation: 'init', dryRun: options.dryRun, ...result };
39
+ const report = {
40
+ schemaVersion: 1,
41
+ operation: 'init',
42
+ dryRun: options.dryRun,
43
+ foundation: options.template ?? 'default',
44
+ capabilities: options.with ?? [],
45
+ tests: !options.bare,
46
+ ...result,
47
+ };
40
48
  const output = options.json ? JSON.stringify(report) : [
41
49
  `${options.dryRun ? 'Planned initialization' : 'Initialization complete'} in ${result.root}`,
50
+ `Foundation: ${options.template ? `example template "${options.template}"` : 'neutral default'}; capabilities: ${options.with?.join(', ') || 'base'}; tests: ${options.bare ? 'omitted' : 'included'}.`,
42
51
  `Created: ${result.created.join(', ')}`,
43
52
  `Kept existing: ${result.skipped.join(', ')}`,
44
53
  `Planned: ${result.planned.join(', ')}`,
@@ -5,10 +5,17 @@ const path = require('path');
5
5
 
6
6
  const json = value => `${JSON.stringify(value, null, 2)}\n`;
7
7
  const TEMPLATES = Object.freeze(['realtime', 'chat', 'site', 'socket', 'dashboard', 'http-ws']);
8
+ const CAPABILITIES = Object.freeze(['auth', 'multiplayer']);
8
9
 
9
- function projectFiles(version, template = 'realtime', root = path.resolve(__dirname, '../..')) {
10
- if (!TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
11
- const { devDependencies, dependencies, overrides } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
10
+ function projectFiles(version, template = null, root = path.resolve(__dirname, '../..'), options = {}) {
11
+ if (template !== null && !TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
12
+ if (options.with !== undefined && !Array.isArray(options.with)) throw new TypeError('Initializer capabilities must be an array.');
13
+ const capabilities = new Set(options.with || []);
14
+ if ([...capabilities].some(capability => !CAPABILITIES.includes(capability))) throw new Error('Unknown initializer capability.');
15
+ const selected = template ?? 'foundation';
16
+ const authenticated = template === 'dashboard' || capabilities.has('auth');
17
+ const multiplayer = capabilities.has('multiplayer');
18
+ const { devDependencies, dependencies, overrides } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
12
19
  const read = relative => fs.readFileSync(path.join(root, 'recipes', relative), 'utf8');
13
20
  const manifest = {
14
21
  name: 'redweb-app', private: true, version: '0.0.0',
@@ -16,18 +23,22 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
16
23
  build: 'tsc && node scripts/copy-assets.cjs',
17
24
  start: 'node dist/app.js',
18
25
  dev: 'nodemon',
19
- test: 'npm run build && node --test test/app.test.cjs test/lifecycle.test.cjs',
20
- 'test:coverage': 'npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/lifecycle.test.cjs',
26
+ ...(!options.bare ? {
27
+ test: 'npm run build && node --test test/app.test.cjs test/lifecycle.test.cjs',
28
+ 'test:coverage': 'npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/lifecycle.test.cjs',
29
+ } : {}),
21
30
  },
22
31
  dependencies: {
23
32
  redweb: `^${version}`,
24
- ...(['chat', 'socket', 'dashboard'].includes(template) ? { zod: devDependencies.zod } : {}),
25
- ...(template === 'dashboard' ? { express: dependencies.express } : {}),
33
+ ...(['chat', 'socket', 'dashboard'].includes(template) || authenticated || multiplayer ? { zod: devDependencies.zod } : {}),
34
+ ...(authenticated ? { express: dependencies.express } : {}),
35
+ ...(multiplayer ? { 'redweb-client': dependencies['redweb-client'] } : {}),
26
36
  },
27
- overrides,
28
- devDependencies: {
29
- typescript: devDependencies.typescript, nodemon: devDependencies.nodemon, ws: dependencies.ws, c8: devDependencies.c8,
30
- ...(template === 'dashboard' ? {
37
+ overrides,
38
+ devDependencies: {
39
+ typescript: devDependencies.typescript, nodemon: devDependencies.nodemon, ws: dependencies.ws,
40
+ ...(!options.bare ? { c8: devDependencies.c8 } : {}),
41
+ ...(authenticated ? {
31
42
  '@types/node': devDependencies['redweb-dashboard-types'].replace('npm:@types/node@', ''),
32
43
  '@types/express': dependencies['@types/express'],
33
44
  } : {}),
@@ -40,10 +51,10 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
40
51
  delay: 200,
41
52
  },
42
53
  };
54
+ if (authenticated) manifest.engines = { node: '>=22.13.0' };
43
55
  if (template === 'dashboard') {
44
- manifest.engines = { node: '>=22.13.0' };
45
56
  manifest.scripts['add-user'] = 'npm run build && node dist/admin.js';
46
- manifest.scripts['test:coverage'] += ' test/rate-window.test.cjs';
57
+ if (!options.bare) manifest.scripts['test:coverage'] += ' test/rate-window.test.cjs';
47
58
  }
48
59
  const files = [
49
60
  { path: 'package.json', content: json(manifest) },
@@ -52,13 +63,15 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
52
63
  compilerOptions: { rootDir: 'src', outDir: 'dist', sourceMap: true },
53
64
  include: ['src/**/*.ts', 'src/**/*.tsx'],
54
65
  }) },
55
- { path: 'src/app.tsx', content: read(`${template}/app.tsx`) },
56
- { path: 'src/app.css', content: read(`${template === 'dashboard' ? template : 'shared'}/app.css`) },
66
+ { path: 'src/app.tsx', content: read(`${selected}/app.tsx`) },
67
+ { path: 'src/app.css', content: read(`${selected === 'dashboard' ? selected : 'shared'}/app.css`) },
57
68
  { path: 'scripts/copy-assets.cjs', content: read('shared/copy-assets.cjs') },
58
- { path: 'test/network.cjs', content: read('shared/network.cjs') },
59
- { path: 'test/app.test.cjs', content: read(`${template}/app.test.cjs`) },
60
- { path: 'test/lifecycle.test.cjs', content: read('shared/lifecycle.test.cjs') },
61
- { path: 'README.md', content: `${read('shared/README.md')}\n${read(`${template}/README.md`)}` },
69
+ ...(!options.bare ? [
70
+ { path: 'test/network.cjs', content: read('shared/network.cjs') },
71
+ { path: 'test/app.test.cjs', content: read(`${selected}/app.test.cjs`) },
72
+ { path: 'test/lifecycle.test.cjs', content: read('shared/lifecycle.test.cjs') },
73
+ ] : []),
74
+ { path: 'README.md', content: `${read('shared/README.md')}\n${read(`${selected}/README.md`)}` },
62
75
  { path: '.gitignore', content: 'node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n' },
63
76
  ];
64
77
  if (template === 'chat') {
@@ -75,7 +88,7 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
75
88
  }
76
89
  if (template === 'dashboard') {
77
90
  files.push({ path: '.npmrc', content: 'engine-strict=true\n' });
78
- files.push({ path: 'test/rate-window.test.cjs', content: read('dashboard/rate-window.test.cjs') });
91
+ if (!options.bare) files.push({ path: 'test/rate-window.test.cjs', content: read('dashboard/rate-window.test.cjs') });
79
92
  for (const name of ['store.ts', 'auth.ts', 'cards.tsx', 'admin.ts']) {
80
93
  files.push({ path: `src/${name}`, content: read(`dashboard/${name}`) });
81
94
  }
@@ -83,4 +96,4 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
83
96
  return Object.freeze(files.map(Object.freeze));
84
97
  }
85
98
 
86
- module.exports = { projectFiles, TEMPLATES };
99
+ module.exports = { projectFiles, CAPABILITIES, TEMPLATES };
@@ -89,6 +89,16 @@ class Documentation {
89
89
  : fence(`npx --yes redweb@${this.channel} init my-${template} --template ${template}\ncd my-${template}\nnpm install --save-exact redweb@${this.channel}\n${acceptance}`, 'sh');
90
90
  }
91
91
 
92
+ foundationSetup() {
93
+ return this.channel === 'unreleased'
94
+ ? [
95
+ 'Replace `TARBALL` with the absolute path to the matching Redweb tarball produced by `npm pack` (quoted if it contains spaces):',
96
+ fence('npx --yes --package TARBALL redweb init my-app\ncd my-app\nnpm install --save-exact TARBALL\nnpm test\nnpm run dev', 'sh'),
97
+ 'This prerelease Redweb artifact is development-only until its release checks finish.',
98
+ ].join('\n\n')
99
+ : fence(`npx --yes redweb@${this.channel} init my-app\ncd my-app\nnpm install --save-exact redweb@${this.channel}\nnpm test\nnpm run dev`, 'sh');
100
+ }
101
+
92
102
  recipe(template) {
93
103
  const files = projectFiles(this.manifest.version, template, this.root).map(file => ({ ...file, content: normalize(file.content) }));
94
104
  const source = `recipes/${template}/README.md`;
@@ -104,28 +114,28 @@ class Documentation {
104
114
  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
115
  }
106
116
 
107
- recipeCode(entry) {
117
+ recipeCode(entry) {
108
118
  const files = projectFiles(this.manifest.version, entry.template, this.root);
109
119
  const file = files.find(file => file.path === entry.file);
110
120
  if (!file) throw new Error(`Unknown documentation recipe file: ${entry.template}/${entry.file}`);
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
- }
121
+ return normalize(file.content);
122
+ }
123
+
124
+ code(entry, field) {
125
+ return entry.recipe ? this.recipeCode(entry.recipe) : entry.codeSource ? this.read(entry.codeSource) : entry[field];
126
+ }
117
127
 
118
128
  topic(topic) {
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)));
129
+ let markdown = `${this.notice()}\n\n${this.links(this.read(topic.source), topic.source)}`;
130
+ markdown = markdown.replace(/<!-- source: ([\w./-]+) -->/g,
131
+ (_match, file) => fence(this.read(file), language(file)));
122
132
  if (topic.recipe) {
123
133
  const { template, file } = topic.recipe;
124
134
  markdown += [
125
135
  '\n## Build and run the complete application', this.setup(template),
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))]),
136
+ `The [complete ${template} recipe](${this.basePath}/recipes/${template}.md) contains every generated file, its real acceptance tests, and deployment instructions.`,
137
+ ...(topic.codeSource ? [] : [`The source below is one of those files, not a standalone program; initialize the whole project before modifying it.`,
138
+ `## Source walkthrough: ${file}`, fence(this.recipeCode(topic.recipe), language(file))]),
129
139
  ].join('\n\n') + '\n';
130
140
  }
131
141
  return { ...topic, markdown };
@@ -135,8 +145,8 @@ class Documentation {
135
145
  const pages = this.topics.map(topic => this.topic(topic));
136
146
  pages.push(...TEMPLATES.map(template => this.recipe(template)));
137
147
  const reference = this.reference;
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') }));
148
+ const api = reference.api.map(section => ({ ...section, usage: this.code(section, 'usage') }));
149
+ const examples = reference.examples.map(example => ({ ...example, code: this.code(example, 'code') }));
140
150
  for (const example of examples) {
141
151
  pages.push({ id: `examples/${example.id}`, title: example.title, summary: example.summary, source: 'docs/reference.json', markdown: [
142
152
  `# ${example.title}`, this.notice(), example.summary,
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, require('./SocketAction').attributes(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;
@@ -28,10 +28,12 @@ class LiveHtmlServer {
28
28
  maxSessions,
29
29
  maxConcurrentRenders,
30
30
  shutdownTimeoutMs = 1000,
31
+ uploadTimeoutMs,
31
32
  heartbeat,
32
33
  authenticate,
33
34
  authenticationTimeoutMs,
34
35
  origins,
36
+ providers,
35
37
  development,
36
38
  server: suppliedApp,
37
39
  ...httpOptions
@@ -54,10 +56,12 @@ class LiveHtmlServer {
54
56
  maxSessions,
55
57
  maxConcurrentRenders,
56
58
  shutdownTimeoutMs,
59
+ uploadTimeoutMs,
57
60
  heartbeat,
58
61
  authenticate,
59
62
  authenticationTimeoutMs,
60
63
  origins,
64
+ providers,
61
65
  logger: httpOptions.logger,
62
66
  });
63
67
  if (this._inspection) this.manager.Renderer = this._inspection.Renderer;
@@ -71,7 +75,7 @@ class LiveHtmlServer {
71
75
  if (this.manager.hasLivePages || socketRoutes.length) {
72
76
  this.sockets = new SocketServer({
73
77
  server: this.http.server,
74
- routes: [...(this.manager.hasLivePages ? [this.manager.route()] : []), ...require('./PageSocketRoute').bindRoutes(this.manager, socketRoutes)],
78
+ routes: [...(this.manager.hasLivePages ? [this.manager.route()] : []), ...require('./PageSocketRoute').bindRoutes(this.manager, socketRoutes)],
75
79
  listen,
76
80
  port: this.http.port,
77
81
  bind: this.http.bind,
@@ -5,7 +5,8 @@ const ReactiveRenderer = require('./ReactiveRenderer');
5
5
  const dataProperty = require('../dataProperty');
6
6
  const { ActionInputError } = require('./ActionDefinition');
7
7
  const { isHtml, markHtml, renderValue } = require('./Html');
8
- const { forEachState, getActionImplementation, getActionDefinition, getStateConfig, isComponentClass } = require('./metadata');
8
+ const { forEachState, getActionImplementation, getActionDefinition, getResourceMetadata, getStateConfig, isComponentClass } = require('./metadata');
9
+ const { LiveResource } = require('./LiveResource');
9
10
 
10
11
  const RUNTIME = new WeakMap();
11
12
  const COMPONENT_RENDER_CONTEXT = new AsyncLocalStorage();
@@ -159,6 +160,7 @@ class LivePage {
159
160
  });
160
161
  });
161
162
  internal.stateActive = true;
163
+ getResourceMetadata(this.constructor).forEach((config, name) => config.resource.bind(this, name, config.select));
162
164
  Object.keys(this).forEach(name => {
163
165
  const value = this[name];
164
166
  if (isComponentClass(value?.constructor) && getStateConfig(this.constructor, name)) {
@@ -226,6 +228,7 @@ class LivePage {
226
228
 
227
229
  _stateChanged(name, value) {
228
230
  if (!getStateConfig(this.constructor, name)) return false;
231
+ LiveResource.refresh(this);
229
232
  const payload = LivePage.statePayload(this, name, value, true);
230
233
  runtime(this).connections.forEach(socket => {
231
234
  const session = socket.__redwebPageSession;
@@ -261,6 +264,7 @@ class LivePage {
261
264
  const internal = runtime(this);
262
265
  if (internal.disposePromise) return internal.disposePromise;
263
266
  internal.disposed = true;
267
+ LiveResource.release(this);
264
268
  internal.connections.clear();
265
269
  internal.disposePromise = Promise.resolve().then(async () => {
266
270
  const tasks = [...internal.children.values()].map(component => LivePage.dispose(component));
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const PAGE_BINDINGS = new WeakMap();
4
+ const UNBOUND = Symbol('unbound');
5
+
6
+ function key(value) {
7
+ if (value === undefined || value === null || value === '') return UNBOUND;
8
+ if (typeof value === 'string') return value;
9
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
10
+ if (typeof value === 'bigint' || typeof value === 'boolean') return value;
11
+ throw new TypeError('Live resource keys must be non-empty strings, finite numbers, bigints, booleans, null, or undefined.');
12
+ }
13
+
14
+ function safeProperty(value) {
15
+ if (typeof value !== 'string' || !value || value.length > 128 || ['__proto__', 'prototype', 'constructor'].includes(value)) {
16
+ throw new TypeError('Live resource properties must be safe non-empty names of at most 128 characters.');
17
+ }
18
+ return value;
19
+ }
20
+
21
+ class LiveResource {
22
+ constructor() {
23
+ this.subscribers = new Map();
24
+ }
25
+
26
+ publish(resourceKey, value) {
27
+ const resolved = key(resourceKey);
28
+ if (resolved === UNBOUND) throw new TypeError('Live resources cannot publish without a key.');
29
+ const subscribers = [...(this.subscribers.get(resolved) || [])];
30
+ subscribers.forEach(binding => {
31
+ if (!binding.active || binding.key !== resolved) return;
32
+ binding.page[binding.property] = value;
33
+ });
34
+ return subscribers.length;
35
+ }
36
+
37
+ bind(page, property, select) {
38
+ if (!page || typeof page !== 'object') throw new TypeError('Live resources bind to page instances.');
39
+ safeProperty(property);
40
+ if (typeof select !== 'function') throw new TypeError('Live resources require a key selector.');
41
+ const binding = { resource: this, page, property, select, key: UNBOUND, active: true };
42
+ const bindings = PAGE_BINDINGS.get(page) || new Set();
43
+ bindings.add(binding);
44
+ PAGE_BINDINGS.set(page, bindings);
45
+ try { this.refresh(binding); }
46
+ catch (error) {
47
+ this.release(binding);
48
+ throw error;
49
+ }
50
+ return binding;
51
+ }
52
+
53
+ refresh(binding) {
54
+ if (!binding.active) return false;
55
+ const next = key(binding.select(binding.page));
56
+ if (Object.is(next, binding.key)) return false;
57
+ if (binding.key !== UNBOUND) {
58
+ const subscribers = this.subscribers.get(binding.key);
59
+ subscribers?.delete(binding);
60
+ if (subscribers?.size === 0) this.subscribers.delete(binding.key);
61
+ }
62
+ binding.key = next;
63
+ if (next !== UNBOUND) {
64
+ const subscribers = this.subscribers.get(next) || new Set();
65
+ subscribers.add(binding);
66
+ this.subscribers.set(next, subscribers);
67
+ }
68
+ return true;
69
+ }
70
+
71
+ release(binding) {
72
+ if (!binding?.active) return false;
73
+ binding.active = false;
74
+ if (binding.key !== UNBOUND) {
75
+ const subscribers = this.subscribers.get(binding.key);
76
+ subscribers?.delete(binding);
77
+ if (subscribers?.size === 0) this.subscribers.delete(binding.key);
78
+ }
79
+ const bindings = PAGE_BINDINGS.get(binding.page);
80
+ bindings?.delete(binding);
81
+ if (bindings?.size === 0) PAGE_BINDINGS.delete(binding.page);
82
+ return true;
83
+ }
84
+
85
+ static refresh(page) {
86
+ PAGE_BINDINGS.get(page)?.forEach(binding => binding.resource.refresh(binding));
87
+ }
88
+
89
+ static release(page) {
90
+ [...(PAGE_BINDINGS.get(page) || [])].forEach(binding => binding.resource.release(binding));
91
+ }
92
+ }
93
+
94
+ function liveResource() { return new LiveResource(); }
95
+
96
+ module.exports = { LiveResource, liveResource };