redweb 0.12.0 → 0.13.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 (167) hide show
  1. package/CHANGELOG.md +166 -9
  2. package/README.md +177 -523
  3. package/bin/redweb.js +11 -20
  4. package/client.d.ts +7 -2
  5. package/config/tsconfig.json +14 -14
  6. package/contract.d.ts +45 -0
  7. package/contract.js +5 -0
  8. package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
  9. package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
  10. package/docs/AGENT_ACCESS.md +35 -0
  11. package/docs/AGENT_EVALUATION.md +58 -0
  12. package/docs/AGENT_READY_ACCEPTANCE.md +763 -0
  13. package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
  14. package/docs/BENCHMARK_VERIFICATION.md +307 -0
  15. package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
  16. package/docs/CLI.md +116 -0
  17. package/docs/CLIENT_DEVELOPMENT.md +152 -0
  18. package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
  19. package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
  20. package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
  21. package/docs/DEVELOPMENT.md +79 -0
  22. package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
  23. package/docs/DOCUMENTATION.md +37 -0
  24. package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
  25. package/docs/GETTING_STARTED.md +58 -0
  26. package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
  27. package/docs/LIVE_HTML.md +169 -21
  28. package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
  29. package/docs/MIGRATION.md +28 -0
  30. package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
  31. package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
  32. package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
  33. package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
  34. package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
  35. package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
  36. package/docs/PRODUCTION_READINESS.md +11 -2
  37. package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
  38. package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
  39. package/docs/RECOVERY_CODE_CENSUS.md +158 -0
  40. package/docs/RECOVERY_COMPARISON.md +103 -0
  41. package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
  42. package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
  43. package/docs/RECOVERY_INVESTIGATION.md +229 -0
  44. package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
  45. package/docs/RELEASE_TRUST.md +58 -0
  46. package/docs/ROOM_AUTHORIZATION.md +49 -0
  47. package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
  48. package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
  49. package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
  50. package/docs/SOAK_VERIFICATION.md +154 -0
  51. package/docs/SOCKET_CONTRACTS.md +39 -0
  52. package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
  53. package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
  54. package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
  55. package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
  56. package/docs/STARTER_REPORT_RETENTION.md +73 -0
  57. package/docs/VERIFICATION_EVIDENCE.md +2 -0
  58. package/docs/generated.json +2154 -0
  59. package/docs/guides/chatroom.md +27 -0
  60. package/docs/guides/http-websocket.md +28 -0
  61. package/docs/guides/jsx-without-react.md +26 -0
  62. package/docs/guides/realtime-dashboard.md +29 -0
  63. package/docs/guides/typed-websockets.md +26 -0
  64. package/docs/reference.json +1207 -0
  65. package/docs/snippets/room-access.tsx +51 -0
  66. package/docs/topics.json +21 -0
  67. package/examples/live-html/chatroom.js +207 -268
  68. package/examples/live-html/chatroom.tsx +167 -0
  69. package/examples/live-html/jsx-page.js +1 -1
  70. package/examples/live-html/jsx-page.tsx +1 -1
  71. package/examples/live-html/tsconfig.json +8 -7
  72. package/index.d.ts +170 -45
  73. package/index.js +2 -0
  74. package/jsx-dev-runtime.js +2 -2
  75. package/jsx-runtime.d.ts +7 -2
  76. package/package.json +88 -7
  77. package/recipes/add/artifact.test.cjs +57 -0
  78. package/recipes/add/live.tsx +18 -0
  79. package/recipes/add/socket-route.ts +24 -0
  80. package/recipes/chat/README.md +22 -0
  81. package/recipes/chat/app.test.cjs +105 -0
  82. package/recipes/chat/app.tsx +9 -0
  83. package/recipes/dashboard/README.md +43 -0
  84. package/recipes/dashboard/admin.ts +21 -0
  85. package/recipes/dashboard/app.css +16 -0
  86. package/recipes/dashboard/app.test.cjs +450 -0
  87. package/recipes/dashboard/app.tsx +86 -0
  88. package/recipes/dashboard/auth.ts +80 -0
  89. package/recipes/dashboard/cards.tsx +102 -0
  90. package/recipes/dashboard/rate-window.test.cjs +17 -0
  91. package/recipes/dashboard/store.ts +120 -0
  92. package/recipes/http-ws/README.md +11 -0
  93. package/recipes/http-ws/app.test.cjs +92 -0
  94. package/recipes/http-ws/app.tsx +36 -0
  95. package/recipes/realtime/README.md +8 -0
  96. package/recipes/realtime/app.test.cjs +15 -0
  97. package/recipes/realtime/app.tsx +28 -0
  98. package/recipes/shared/README.md +40 -0
  99. package/recipes/shared/app.css +8 -0
  100. package/recipes/shared/copy-assets.cjs +8 -0
  101. package/recipes/shared/network.cjs +59 -0
  102. package/recipes/shared/run-app.test.cjs +158 -0
  103. package/recipes/shared/run-app.ts +50 -0
  104. package/recipes/site/README.md +4 -0
  105. package/recipes/site/app.test.cjs +19 -0
  106. package/recipes/site/app.tsx +25 -0
  107. package/recipes/socket/README.md +39 -0
  108. package/recipes/socket/app.test.cjs +85 -0
  109. package/recipes/socket/app.tsx +30 -0
  110. package/recipes/socket/contract.ts +12 -0
  111. package/recipes/socket/handlers.ts +40 -0
  112. package/src/OwnedServerLifecycle.js +66 -0
  113. package/src/access/AccessPolicy.js +37 -0
  114. package/src/access/AuthenticationFailure.js +13 -0
  115. package/src/access/RequestFailure.js +33 -0
  116. package/src/access/failure-codes.json +25 -0
  117. package/src/async/BoundedOperation.js +62 -0
  118. package/src/cli/ActionReferences.js +193 -0
  119. package/src/cli/AdditionLayout.js +140 -0
  120. package/src/cli/FilePlan.js +94 -0
  121. package/src/cli/ProjectAddition.js +60 -0
  122. package/src/cli/ProjectConfig.js +26 -0
  123. package/src/cli/ProjectDoctor.js +112 -0
  124. package/src/cli/ProjectInitializer.js +20 -30
  125. package/src/cli/SourceInspector.js +207 -0
  126. package/src/cli/StaticSource.js +192 -0
  127. package/src/cli/arguments.js +62 -0
  128. package/src/cli/formatCommand.js +10 -0
  129. package/src/cli/run.js +57 -0
  130. package/src/cli/templates.js +86 -87
  131. package/src/context/RequestSnapshot.js +41 -0
  132. package/src/dataProperty.js +11 -0
  133. package/src/development/DevelopmentPageManager.js +48 -0
  134. package/src/development/Inspection.js +104 -0
  135. package/src/development/ObservedRenderer.js +42 -0
  136. package/src/development/description.js +35 -0
  137. package/src/development/loopbackRequest.js +27 -0
  138. package/src/development/refreshBrowser.js +96 -0
  139. package/src/development/refreshStyles.js +9 -0
  140. package/src/development/settings.js +17 -0
  141. package/src/docs/Documentation.js +182 -0
  142. package/src/htmx/ActionDefinition.js +44 -0
  143. package/src/htmx/Jsx.js +24 -8
  144. package/src/htmx/LiveHtmlServer.js +41 -19
  145. package/src/htmx/LivePage.js +63 -13
  146. package/src/htmx/PageIdentity.js +32 -0
  147. package/src/htmx/PageLifetime.js +37 -0
  148. package/src/htmx/PageManager.js +203 -74
  149. package/src/htmx/ReactiveRenderer.js +241 -0
  150. package/src/htmx/StaticExporter.js +1 -1
  151. package/src/htmx/TemplateRenderer.js +13 -7
  152. package/src/htmx/browserRuntime.js +2 -93
  153. package/src/htmx/metadata.js +19 -7
  154. package/src/validation/ActionInputError.js +12 -0
  155. package/src/validation/SchemaValidator.js +38 -0
  156. package/src/ws/AdmissionPolicy.js +24 -23
  157. package/src/ws/BaseSocketServer.js +53 -38
  158. package/src/ws/ContractValidationError.js +12 -0
  159. package/src/ws/HeartbeatMonitor.js +19 -7
  160. package/src/ws/ProtocolPolicy.js +1 -1
  161. package/src/ws/RoomAccess.js +82 -0
  162. package/src/ws/RoomRegistry.js +56 -6
  163. package/src/ws/RouteRuntime.js +56 -10
  164. package/src/ws/SocketContract.js +112 -0
  165. package/src/ws/SocketRoute.js +18 -0
  166. package/src/ws/protocol-schema.json +6 -1
  167. package/examples/live-html/chatroom.ts +0 -217
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ module.exports = `
4
+ :host { position: fixed; inset: auto 1rem 1rem auto; z-index: 2147483647; max-width: min(28rem, calc(100vw - 2rem)); font: 14px/1.5 system-ui, sans-serif; }
5
+ aside { background: #17202b; color: #fff; border: 1px solid #718198; border-radius: 8px; padding: 16px; box-shadow: 0 4px 24px #0006; }
6
+ p { margin: 0 0 12px; }
7
+ button { background: #fff; color: #17202b; border: 2px solid #fff; border-radius: 4px; font: inherit; padding: 6px 12px; cursor: pointer; }
8
+ button:focus-visible { outline: 3px solid #6edcff; outline-offset: 3px; }
9
+ `;
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ function developmentSettings(options, names = ['inspect'], defaults = {}) {
4
+ if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options) ||
5
+ Object.keys(options).some(key => !names.includes(key)) ||
6
+ Object.values(options).some(value => value !== undefined && typeof value !== 'boolean'))) {
7
+ throw new TypeError(`development must contain only optional boolean ${names.join('/')} options.`);
8
+ }
9
+ const settings = { ...defaults };
10
+ for (const [name, value] of Object.entries(options || {})) if (value !== undefined) settings[name] = value;
11
+ if (process.env.NODE_ENV === 'production' && Object.values(settings).some(Boolean)) {
12
+ throw new Error('Development features cannot be enabled in production.');
13
+ }
14
+ return settings;
15
+ }
16
+
17
+ module.exports = developmentSettings;
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { createHash } = require('crypto');
6
+ const { projectFiles, TEMPLATES } = require('../cli/templates');
7
+
8
+ const normalize = text => text.replace(/\r\n/g, '\n');
9
+ const hash = text => createHash('sha256').update(text).digest('hex');
10
+ const fence = (text, language) => {
11
+ const runs = text.match(/`+/g) || [];
12
+ const delimiter = '`'.repeat(Math.max(3, ...runs.map(run => run.length + 1)));
13
+ return `${delimiter}${language}\n${text.trimEnd()}\n${delimiter}`;
14
+ };
15
+
16
+ /** Build-time content only. Loading Redweb's runtime does not load documentation. */
17
+ class Documentation {
18
+ constructor(root, channel = 'unreleased') {
19
+ this.root = root;
20
+ this.manifest = JSON.parse(this.read('package.json'));
21
+ if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(this.manifest.version)) {
22
+ throw new Error('Documentation requires a valid package version.');
23
+ }
24
+ if (channel !== 'unreleased' && channel !== this.manifest.version) {
25
+ throw new Error('Documentation channel must be unreleased or the exact package version.');
26
+ }
27
+ if (channel !== 'unreleased' && /^## Unreleased\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m.exec(this.read('CHANGELOG.md'))?.[1].trim()) {
28
+ throw new Error('Move unreleased changes into the versioned changelog before building release documentation.');
29
+ }
30
+ if (channel !== 'unreleased' && !this.read('CHANGELOG.md').split('\n').includes(`## ${channel}`)) {
31
+ throw new Error('Release documentation requires its versioned changelog section.');
32
+ }
33
+ this.channel = channel;
34
+ this.basePath = `/docs/reference/${channel}`;
35
+ this.topics = JSON.parse(this.read('docs/topics.json'));
36
+ this.reference = JSON.parse(this.read('docs/reference.json'));
37
+ this.sourceLinks = new Map(this.topics.map(topic => [topic.source, `${this.basePath}/${topic.id}.md`]));
38
+ for (const example of this.reference.examples) {
39
+ if (example.codeSource) this.sourceLinks.set(example.codeSource, `${this.basePath}/examples/${example.id}.md`);
40
+ }
41
+ for (const template of TEMPLATES) {
42
+ for (const file of projectFiles(this.manifest.version, template, this.root)) {
43
+ const source = file.path === 'test/app.test.cjs' ? 'app.test.cjs' : file.path.replace(/^src\//, '');
44
+ this.sourceLinks.set(`recipes/${template}/${source}`, `${this.basePath}/recipes/${template}/files/${file.path}`);
45
+ }
46
+ this.sourceLinks.set(`recipes/${template}/README.md`, `${this.basePath}/recipes/${template}.md`);
47
+ }
48
+ }
49
+
50
+ read(file) { return normalize(fs.readFileSync(path.join(this.root, file), 'utf8')); }
51
+
52
+ links(markdown, source) {
53
+ // Canonical Markdown links are rewritten outside code fences only.
54
+ let delimiter = '';
55
+ return markdown.split('\n').map(line => {
56
+ const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
57
+ if (marker) {
58
+ if (!delimiter) delimiter = marker;
59
+ else if (marker[0] === delimiter[0] && marker.length >= delimiter.length) delimiter = '';
60
+ return line;
61
+ }
62
+ if (delimiter) return line;
63
+ return line.replace(/\]\(([^\s)]+)\)/g, (match, target) => {
64
+ if (/^(?:[a-z]+:|\/|#)/i.test(target)) return match;
65
+ const [file, anchor = ''] = target.split('#');
66
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(source), file));
67
+ const destination = this.sourceLinks.get(resolved);
68
+ if (!destination) throw new Error(`Undocumented link target: ${source} -> ${target}`);
69
+ return `](${destination}${anchor ? `#${anchor}` : ''})`;
70
+ });
71
+ }).join('\n');
72
+ }
73
+
74
+ notice() {
75
+ return this.channel === 'unreleased'
76
+ ? `> Unreleased development documentation. Package metadata is ${this.manifest.version}, but these features are not claimed to be published in that npm version. Use the matching Redweb tarball described in the recipe setup; its published client dependency installs automatically. Do not install latest and assume compatibility.`
77
+ : `> Documentation for Redweb ${this.channel}. Install that exact version when following these examples.`;
78
+ }
79
+
80
+ setup(template) {
81
+ if (!TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
82
+ const acceptance = `${template === 'dashboard' ? 'npm run add-user -- alice\n' : ''}npm test\nnpm run dev`;
83
+ return this.channel === 'unreleased'
84
+ ? [
85
+ 'Replace `TARBALL` with the absolute path to the matching Redweb tarball produced by `npm pack` (quoted if it contains spaces). This is an explicit prerequisite, not an npm package name. Both commands must use the same tarball. The published redweb-client dependency installs automatically; no separate client checkout or linking is required:',
86
+ fence(`npx --yes --package TARBALL redweb init my-${template} --template ${template}\ncd my-${template}\nnpm install --save-exact TARBALL\n${acceptance}`, 'sh'),
87
+ 'This prerelease Redweb artifact is development-only until its release checks finish. For released applications, use an available versioned release guide.',
88
+ ].join('\n\n')
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
+ }
91
+
92
+ recipe(template) {
93
+ const files = projectFiles(this.manifest.version, template, this.root).map(file => ({ ...file, content: normalize(file.content) }));
94
+ const source = `recipes/${template}/README.md`;
95
+ const explanation = this.links(this.read(source), source);
96
+ const markdown = [
97
+ `# ${template[0].toUpperCase()}${template.slice(1)}: complete application`, this.notice(),
98
+ explanation, '## Setup and acceptance', this.setup(template),
99
+ this.read('recipes/shared/README.md').replace(/^# Your Redweb application\n/, ''),
100
+ '## Exact generated files',
101
+ 'These files come from the initializer itself. The tests below run real listeners; they are not illustrative pseudocode. The generated manifest uses the package metadata version; the installation step above pins the matching artifact or release.',
102
+ ...files.map(file => `### ${file.path}\n\n${fence(file.content, language(file.path))}`),
103
+ ].join('\n\n') + '\n';
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
+ }
106
+
107
+ recipeCode(entry) {
108
+ const files = projectFiles(this.manifest.version, entry.template, this.root);
109
+ const file = files.find(file => file.path === entry.file);
110
+ if (!file) throw new Error(`Unknown documentation recipe file: ${entry.template}/${entry.file}`);
111
+ return normalize(file.content);
112
+ }
113
+
114
+ topic(topic) {
115
+ let markdown = `${this.notice()}\n\n${this.links(this.read(topic.source), topic.source)}`;
116
+ if (topic.recipe) {
117
+ const { template, file } = topic.recipe;
118
+ markdown += [
119
+ '\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)),
122
+ ].join('\n\n') + '\n';
123
+ }
124
+ return { ...topic, markdown };
125
+ }
126
+
127
+ build() {
128
+ const pages = this.topics.map(topic => this.topic(topic));
129
+ pages.push(...TEMPLATES.map(template => this.recipe(template)));
130
+ 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 }));
133
+ for (const example of examples) {
134
+ pages.push({ id: `examples/${example.id}`, title: example.title, summary: example.summary, source: 'docs/reference.json', markdown: [
135
+ `# ${example.title}`, this.notice(), example.summary,
136
+ example.recipe ? `Use the [complete ${example.recipe.template} recipe](${this.basePath}/recipes/${example.recipe.template}.md) for setup, files, and tests.` : 'This pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.',
137
+ fence(example.code, example.language || 'js'),
138
+ '## Notes and boundaries', example.notes.map(note => `- ${note}`).join('\n'),
139
+ ].join('\n\n') + '\n' });
140
+ }
141
+ for (const section of api) {
142
+ const article = section.article;
143
+ pages.push({ id: `api/${section.id}`, title: section.name, summary: section.summary, source: 'docs/reference.json', markdown: [
144
+ `# ${section.name}`, this.notice(), section.summary,
145
+ '## Explain it like I\u2019m five', article.eli5,
146
+ '## When should I use it?', article.useWhen,
147
+ '## Follow the example',
148
+ section.recipe ? `This source is part of the [complete ${section.recipe.template} recipe](${this.basePath}/recipes/${section.recipe.template}.md). Follow its setup and tests.` : 'This API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.',
149
+ fence(section.usage, section.language || (section.type === 'Live HTML' ? 'tsx' : 'js')),
150
+ article.walkthrough.map((step, index) => `${index + 1}. ${step}`).join('\n'),
151
+ ...(section.options ? ['## Options', section.options.map(option => `- ${option}`).join('\n')] : []),
152
+ '## Methods and members', section.methods.map(method => `### ${method.name}\n\n${method.detail}`).join('\n\n'),
153
+ '## What should I watch for?', article.watchFor,
154
+ ].join('\n\n') + '\n' });
155
+ }
156
+ const apiFiles = ['index.d.ts', 'client.d.ts', 'contract.d.ts', 'jsx-runtime.d.ts', 'jsx-dev-runtime.d.ts'];
157
+ pages.push({ id: 'api-types', title: 'Complete public TypeScript declarations', summary: 'Exact shipped signatures, options, and public types; not standalone application snippets.', source: 'index.d.ts', markdown: [
158
+ '# Public TypeScript API', this.notice(),
159
+ ...apiFiles.map(file => `## ${file}\n\n${fence(this.read(file), 'ts')}`),
160
+ ].join('\n\n') + '\n' });
161
+ for (const page of pages) {
162
+ page.url = `${this.basePath}/${page.id}.md`;
163
+ page.sha256 = hash(page.markdown);
164
+ }
165
+ const llms = [
166
+ '# Redweb',
167
+ '> Server-rendered TypeScript/TSX sites with server-owned state/actions and routed WebSockets on Node.js.',
168
+ this.notice(),
169
+ 'TSX is not React. State is assignment-driven. Shared memory is not durable storage. Socket routes select services; message types select handlers. Applications own identity, authorization, persistence, and delivery reconciliation.',
170
+ '## Guides and complete recipes',
171
+ ...pages.map(page => `- [${page.title}](${page.url}): ${page.summary}`),
172
+ ].join('\n\n') + '\n';
173
+ return { schemaVersion: 1, packageVersion: this.manifest.version, channel: this.channel, basePath: this.basePath, llms, pages, api, examples };
174
+ }
175
+ }
176
+
177
+ function language(file) {
178
+ const extension = path.extname(file);
179
+ return ({ '.json': 'json', '.tsx': 'tsx', '.ts': 'ts', '.cjs': 'js', '.css': 'css', '.md': 'md' })[extension] || 'text';
180
+ }
181
+
182
+ module.exports = { Documentation, fence };
@@ -0,0 +1,44 @@
1
+ const { SchemaValidator } = require('../validation/SchemaValidator');
2
+ const { AccessPolicy } = require('../access/AccessPolicy');
3
+ const ActionInputError = require('../validation/ActionInputError');
4
+
5
+ class ActionDefinition {
6
+ constructor(options = {}) {
7
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Action options must be an object.');
8
+ if (Object.keys(options).some(key => !['input', 'validationTimeoutMs', 'authorize', 'authorizationTimeoutMs'].includes(key))) throw new TypeError('Unknown action option.');
9
+ if (options.input === undefined && options.validationTimeoutMs !== undefined) throw new TypeError('Action validationTimeoutMs requires an input schema.');
10
+ this.validator = options.input === undefined ? null : new SchemaValidator(options.input, options.validationTimeoutMs);
11
+ this.authorization = new AccessPolicy(options.authorize, options.authorizationTimeoutMs);
12
+ Object.freeze(this);
13
+ }
14
+
15
+ async arguments(args, context) {
16
+ if (!this.validator && !this.authorization.authorize) return args;
17
+ if (this.validator ? args.length !== 1 : args.length > 1) throw new ActionInputError();
18
+ const controller = new AbortController();
19
+ const signal = context?.signal;
20
+ const socket = context?.socket;
21
+ const abort = () => controller.abort();
22
+ socket?.once('close', abort);
23
+ signal?.addEventListener('abort', abort, { once: true });
24
+ if (signal?.aborted || (socket && socket.readyState !== 1)) abort();
25
+ try {
26
+ let input = args[0];
27
+ if (this.validator) input = await this.validator.parse(input, controller.signal);
28
+ await this.authorization.check(Object.freeze({ ...context, signal: controller.signal }), input);
29
+ return [input];
30
+ } catch (error) {
31
+ if (error.reason === 'input') throw new ActionInputError();
32
+ if (error.reason === 'timeout') throw new ActionInputError('ACTION_VALIDATION_TIMEOUT');
33
+ if (error.reason === 'cancelled') throw new ActionInputError('ACTION_CANCELLED');
34
+ // ValidationFailure is already sanitized; policy failures preserve their own boundary.
35
+ if (error.reason === 'validator') throw new Error('Action input validator failed.');
36
+ throw error;
37
+ } finally {
38
+ socket?.off('close', abort);
39
+ signal?.removeEventListener('abort', abort);
40
+ }
41
+ }
42
+ }
43
+
44
+ module.exports = { ActionDefinition, ActionInputError };
package/src/htmx/Jsx.js CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  const { isHtml, renderAttributeValue, renderValue, trustedHtml } = require('./Html');
4
4
  const synchronous = require('./synchronous');
5
+ const ReactiveRenderer = require('./ReactiveRenderer');
5
6
 
6
7
  const Fragment = Symbol('redweb.Fragment');
8
+ const KEYS = new WeakMap();
7
9
  const NAME = /^[A-Za-z][A-Za-z0-9:._-]*$/;
8
10
  const VOID_ELEMENTS = new Set([
9
11
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
@@ -14,10 +16,17 @@ const TERMINAL_ELEMENTS = new Set(['plaintext']);
14
16
  const BOOLEAN_VALUE_ATTRIBUTES = new Set(['contenteditable', 'draggable', 'spellcheck', 'writingsuggestions']);
15
17
  const ATTRIBUTE_ALIASES = Object.freeze({ className: 'class', htmlFor: 'for' });
16
18
 
17
- function renderChild(value) {
19
+ function renderChild(value, keys = new Set()) {
18
20
  if (value === null || value === undefined || typeof value === 'boolean') return '';
19
- if (Array.isArray(value)) return value.map(renderChild).join('');
20
- if (isHtml(value)) return renderValue(value);
21
+ if (Array.isArray(value)) return value.map(child => renderChild(child, keys)).join('');
22
+ if (isHtml(value)) {
23
+ const key = KEYS.get(value);
24
+ if (key !== undefined) {
25
+ if (keys.has(key)) throw new Error('Duplicate JSX sibling key.');
26
+ keys.add(key);
27
+ }
28
+ return renderValue(value);
29
+ }
21
30
  if (['string', 'number', 'bigint'].includes(typeof value)) return renderValue(value);
22
31
  throw new TypeError('JSX children must be text, numbers, HtmlFragment values, or arrays of those values.');
23
32
  }
@@ -72,15 +81,22 @@ function renderComponent(Component, properties) {
72
81
  return Array.isArray(result) ? trustedHtml(renderValue(result)) : result;
73
82
  }
74
83
 
75
- function createElement(type, properties) {
84
+ function createElement(type, properties, key) {
85
+ const reactive = ReactiveRenderer.jsx();
76
86
  const props = properties == null ? {} : properties;
77
87
  if (!props || typeof props !== 'object' || Array.isArray(props)) {
78
88
  throw new TypeError('JSX properties must be an object.');
79
89
  }
80
- if (type === Fragment) return trustedHtml(renderChild(props.children));
81
- if (typeof type === 'string') return renderIntrinsic(type, props);
82
- if (typeof type === 'function') return renderComponent(type, props);
83
- throw new TypeError('JSX element types must be intrinsic names or function components.');
90
+ let result;
91
+ if (type === Fragment) result = trustedHtml(renderChild(props.children));
92
+ else if (typeof type === 'string') result = renderIntrinsic(type, props);
93
+ else if (typeof type === 'function') result = renderComponent(type, props);
94
+ else throw new TypeError('JSX element types must be intrinsic names or function components.');
95
+ const elementKey = key ?? props.key;
96
+ if (!reactive || elementKey === undefined) return result;
97
+ const keyed = trustedHtml(ReactiveRenderer.key(renderValue(result), elementKey));
98
+ if (elementKey !== null) KEYS.set(keyed, String(elementKey));
99
+ return keyed;
84
100
  }
85
101
 
86
102
  module.exports = { Fragment, createElement, renderChild };
@@ -3,6 +3,10 @@ const HttpServer = require('../http/HttpServer');
3
3
  const HttpsServer = require('../http/HttpsServer');
4
4
  const SocketServer = require('../ws/SocketServer');
5
5
  const { PageManager } = require('./PageManager');
6
+ const { createInspection } = require('../development/Inspection');
7
+ const developmentSettings = require('../development/settings');
8
+ const OwnedServerLifecycle = require('../OwnedServerLifecycle');
9
+ const { listenServer, validateListenerOptions } = require('../serverLifecycle');
6
10
 
7
11
  class LiveHtmlServer {
8
12
  constructor(options = {}) {
@@ -19,15 +23,20 @@ class LiveHtmlServer {
19
23
  shutdownTimeoutMs = 1000,
20
24
  heartbeat,
21
25
  authenticate,
26
+ authenticationTimeoutMs,
22
27
  origins,
28
+ development,
23
29
  server: suppliedApp,
24
30
  ...httpOptions
25
31
  } = options;
32
+ const settings = developmentSettings(development, ['inspect', 'refresh'], { refresh: process.env.REDWEB_DEV_REFRESH === '1' });
33
+ this._inspection = createInspection({ inspect: settings.inspect });
26
34
  const app = suppliedApp === undefined ? express() : suppliedApp;
27
35
  if (!app || typeof app.get !== 'function' || typeof app.use !== 'function') {
28
36
  throw new TypeError('`server` must be an Express-compatible application.');
29
37
  }
30
- this.manager = new PageManager({
38
+ const Manager = settings.refresh ? require('../development/DevelopmentPageManager') : PageManager;
39
+ this.manager = new Manager({
31
40
  pages,
32
41
  templateRoot,
33
42
  paths: livePaths,
@@ -37,33 +46,47 @@ class LiveHtmlServer {
37
46
  shutdownTimeoutMs,
38
47
  heartbeat,
39
48
  authenticate,
49
+ authenticationTimeoutMs,
40
50
  origins,
41
51
  logger: httpOptions.logger,
42
52
  });
53
+ if (this._inspection) this.manager.Renderer = this._inspection.Renderer;
43
54
  this.manager.mount(app);
44
55
  const listen = httpOptions.listen ?? true;
45
56
  const ServerClass = httpOptions.ssl ? HttpsServer : HttpServer;
46
- this.http = new ServerClass({ ...httpOptions, server: app, listen: this.manager.hasLivePages ? false : listen });
47
- if (this.manager.hasLivePages) {
48
- const Route = this.manager.route();
49
- this.sockets = new SocketServer({
50
- server: this.http.server,
51
- routes: [Route],
52
- listen,
53
- port: this.http.port,
54
- bind: this.http.bind,
55
- listenCallback: this.http.listenCallback,
56
- logger: this.http.logger,
57
- closeServerOnShutdown: false,
58
- });
59
- } else {
60
- this.sockets = null;
61
- }
57
+ this.http = new ServerClass({ ...httpOptions, server: app, listen: false });
58
+ validateListenerOptions({ ...this.http, listen });
59
+ this._ownedServer = new OwnedServerLifecycle(this.http.server);
60
+ try {
61
+ if (this.manager.hasLivePages) {
62
+ const Route = this.manager.route();
63
+ this.sockets = new SocketServer({
64
+ server: this.http.server,
65
+ routes: [Route],
66
+ listen,
67
+ port: this.http.port,
68
+ bind: this.http.bind,
69
+ listenCallback: this.http.listenCallback,
70
+ logger: this.http.logger,
71
+ closeServerOnShutdown: false,
72
+ });
73
+ } else {
74
+ this.sockets = null;
75
+ if (listen) listenServer(this.http.server, {
76
+ port: this.http.port, bind: this.http.bind, callback: this.http.listenCallback,
77
+ logger: this.http.logger, name: httpOptions.ssl ? 'HttpsServer' : 'HttpServer',
78
+ });
79
+ }
80
+ } catch (error) { this._ownedServer.dispose(); throw error; }
62
81
  this.app = this.http.app;
63
82
  this.server = this.http.server;
64
83
  this._shutdownPromise = null;
65
84
  }
66
85
 
86
+ revoke(principal) { return this.manager.revoke(principal); }
87
+
88
+ inspect() { return this._inspection ? this._inspection.snapshot(this) : null; }
89
+
67
90
  shutdown() {
68
91
  if (!this._shutdownPromise) {
69
92
  this._shutdownPromise = this.performShutdown();
@@ -80,9 +103,8 @@ class LiveHtmlServer {
80
103
  try { await this.manager.shutdown(); }
81
104
  catch (error) {
82
105
  errors.push(error);
83
- if (error?.code === 'LIVE_HTML_SHUTDOWN_TIMEOUT') this.server.closeAllConnections?.();
84
106
  }
85
- try { await this.http.shutdown(); }
107
+ try { await this._ownedServer.close(this.manager.shutdownTimeoutMs, () => this.http.shutdown()); }
86
108
  catch (error) { errors.push(error); }
87
109
  if (errors.length) throw new AggregateError(errors, 'Live HTML shutdown failed.');
88
110
  }
@@ -1,8 +1,11 @@
1
1
  const { AsyncLocalStorage } = require('async_hooks');
2
2
  const HtmlRenderer = require('./HtmlRenderer');
3
3
  const TemplateRenderer = require('./TemplateRenderer');
4
+ const ReactiveRenderer = require('./ReactiveRenderer');
5
+ const dataProperty = require('../dataProperty');
6
+ const { ActionInputError } = require('./ActionDefinition');
4
7
  const { isHtml, markHtml, renderValue } = require('./Html');
5
- const { forEachState, getActionImplementation, getStateConfig, isComponentClass } = require('./metadata');
8
+ const { forEachState, getActionImplementation, getActionDefinition, getStateConfig, isComponentClass } = require('./metadata');
6
9
 
7
10
  const RUNTIME = new WeakMap();
8
11
  const COMPONENT_RENDER_CONTEXT = new AsyncLocalStorage();
@@ -67,12 +70,29 @@ class LivePage {
67
70
  static attach(page, socket, context) { return LivePage.prototype._attach.call(page, socket, context); }
68
71
  static detach(page, socket, context) { return LivePage.prototype._detach.call(page, socket, context); }
69
72
  static dispose(page) { return LivePage.prototype.dispose.call(page); }
70
- static invoke(page, name, args, context) { return LivePage.prototype._invoke.call(page, name, args, context); }
73
+ static invoke(page, name, args, context, beforeInvoke) { return LivePage.prototype._invoke.call(page, name, args, context, beforeInvoke); }
71
74
  static loadComponents(page, context) { return LivePage.prototype._loadComponents.call(page, context); }
72
75
  static setFromClient(page, name, value) { return LivePage.prototype._setFromClient.call(page, name, value); }
73
76
 
74
- static statePayload(page, name, value) {
77
+ // Read owned runtime structure only: no application fields or state values.
78
+ static describe(page, describeList) {
79
+ const { list, members } = require('../development/description');
80
+ const limited = describeList || list;
75
81
  const internal = runtime(page);
82
+ return { disposed: internal.disposed, components: limited(internal.components, ([id, component]) => ({ id, ...members(dataProperty(component, 'constructor'), limited) })) };
83
+ }
84
+
85
+ static statePayload(page, name, value, lazy = false) {
86
+ const internal = runtime(page);
87
+ if (lazy) {
88
+ let payload;
89
+ const materialize = () => payload ||= LivePage.statePayload(page, name, value);
90
+ return {
91
+ name, component: internal.componentId || undefined,
92
+ get value() { return materialize().value; },
93
+ get html() { return materialize().html; },
94
+ };
95
+ }
76
96
  const payload = HtmlRenderer.statePayload(name, value, page);
77
97
  if (!internal.componentId) return payload;
78
98
  payload.component = internal.componentId;
@@ -80,6 +100,13 @@ class LivePage {
80
100
  return payload;
81
101
  }
82
102
 
103
+ static snapshots(page, lazy = false) {
104
+ const values = [];
105
+ forEachState(page.constructor, (_options, name) => values.push(LivePage.statePayload(page, name, page[name], lazy)));
106
+ for (const child of runtime(page).children.values()) values.push(...LivePage.snapshots(child, lazy));
107
+ return values;
108
+ }
109
+
83
110
  static withRenderContext(context, render) {
84
111
  return COMPONENT_RENDER_CONTEXT.run(context, render);
85
112
  }
@@ -119,8 +146,12 @@ class LivePage {
119
146
  Object.defineProperty(this, name, {
120
147
  configurable: true,
121
148
  enumerable: true,
122
- get: () => internal.stateValues.get(name),
149
+ get: () => {
150
+ ReactiveRenderer.read(this, name);
151
+ return internal.stateValues.get(name);
152
+ },
123
153
  set: value => {
154
+ ReactiveRenderer.assertWritable();
124
155
  const previous = internal.stateValues.get(name);
125
156
  internal.stateValues.set(name, value);
126
157
  if (previous !== value) LivePage.prototype._stateChanged.call(this, name, value);
@@ -141,11 +172,13 @@ class LivePage {
141
172
  _renderComponent() {
142
173
  const internal = runtime(this);
143
174
  if (!internal.componentId) throw new Error('Components must be owned by a page field before rendering.');
144
- const source = this.render?.(COMPONENT_RENDER_CONTEXT.getStore());
145
- if (source && typeof source.then === 'function') throw new TypeError('Component render() must be synchronous.');
146
- if (source === undefined) throw new Error(`${this.constructor.name || 'Component'} must provide render().`);
147
- const markup = isHtml(source) ? renderValue(source) : HtmlRenderer.render(source.toString(), this);
148
- return TemplateRenderer.component(markup, internal.componentId);
175
+ return ReactiveRenderer.component(this, internal.componentId, () => {
176
+ const source = this.render?.(COMPONENT_RENDER_CONTEXT.getStore());
177
+ if (source && typeof source.then === 'function') throw new TypeError('Component render() must be synchronous.');
178
+ if (source === undefined) throw new Error(`${this.constructor.name || 'Component'} must provide render().`);
179
+ const markup = isHtml(source) ? renderValue(source) : HtmlRenderer.render(source.toString(), this);
180
+ return TemplateRenderer.component(markup, internal.componentId);
181
+ });
149
182
  }
150
183
 
151
184
  _component(id) {
@@ -154,6 +187,7 @@ class LivePage {
154
187
 
155
188
  async _loadComponents(context) {
156
189
  for (const component of runtime(this).children.values()) {
190
+ if (runtime(this).disposed || context?.signal?.aborted) return;
157
191
  await component.loading?.(context);
158
192
  await LivePage.loadComponents(component, context);
159
193
  }
@@ -162,14 +196,17 @@ class LivePage {
162
196
  _attach(socket, context) {
163
197
  const internal = runtime(this);
164
198
  if (internal.disposed) throw new Error('Cannot connect a disposed page.');
199
+ if (context?.signal?.aborted) throw new ActionInputError('ACTION_CANCELLED');
165
200
  internal.connections.add(socket);
166
201
  forEachState(this.constructor, (_options, name) => {
202
+ if (socket.__redwebPageSession?.renderer) return;
167
203
  const payload = LivePage.statePayload(this, name, this[name]);
168
204
  socket.sendEvent?.('redweb:state', payload);
169
205
  });
170
206
  const connected = this.connected?.(context);
171
207
  return Promise.resolve(connected).then(async result => {
172
208
  for (const component of internal.children.values()) {
209
+ if (internal.disposed || context?.signal?.aborted || !internal.connections.has(socket)) return false;
173
210
  await LivePage.attach(component, socket, context);
174
211
  }
175
212
  return result;
@@ -189,8 +226,14 @@ class LivePage {
189
226
 
190
227
  _stateChanged(name, value) {
191
228
  if (!getStateConfig(this.constructor, name)) return false;
192
- const payload = LivePage.statePayload(this, name, value);
193
- runtime(this).connections.forEach(socket => socket.sendEvent?.('redweb:state', payload));
229
+ const payload = LivePage.statePayload(this, name, value, true);
230
+ runtime(this).connections.forEach(socket => {
231
+ const session = socket.__redwebPageSession;
232
+ if (session?.lifetime?.revoked) return;
233
+ const renderer = session?.renderer;
234
+ if (renderer) renderer.invalidate(this, name, payload);
235
+ else socket.sendEvent?.('redweb:state', payload);
236
+ });
194
237
  return true;
195
238
  }
196
239
 
@@ -200,11 +243,18 @@ class LivePage {
200
243
  this[name] = value;
201
244
  }
202
245
 
203
- async _invoke(name, args, context) {
246
+ async _invoke(name, args, context, beforeInvoke) {
204
247
  const implementation = getActionImplementation(this.constructor, name);
205
248
  if (!implementation || this[name] !== implementation) throw new Error(`Unknown page action "${name}".`);
206
249
  if (!Array.isArray(args)) throw new TypeError('Action arguments must be an array.');
207
- return implementation.call(this, ...args, context);
250
+ const definition = getActionDefinition(this.constructor, name);
251
+ const validated = await definition.arguments(args, context);
252
+ if (beforeInvoke) await beforeInvoke();
253
+ if (runtime(this).disposed || context?.signal?.aborted || (context?.socket?.readyState !== undefined && context.socket.readyState !== 1)) {
254
+ throw new ActionInputError('ACTION_CANCELLED');
255
+ }
256
+ if (this[name] !== implementation) throw new Error(`Unknown page action "${name}".`);
257
+ return implementation.call(this, ...validated, context);
208
258
  }
209
259
 
210
260
  async dispose() {
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ const { BoundedOperation, OperationInterrupted } = require('../async/BoundedOperation');
3
+ const AuthenticationFailure = require('../access/AuthenticationFailure');
4
+
5
+ function isPrincipal(value) {
6
+ return typeof value === 'string' || typeof value === 'bigint' || value === true || (typeof value === 'number' && Number.isFinite(value));
7
+ }
8
+
9
+ /** Bounded identity lookup; application code owns credential storage and revocation. */
10
+ class PageIdentity {
11
+ constructor(authenticate, timeoutMs) {
12
+ if (authenticate !== undefined && typeof authenticate !== 'function') throw new TypeError('`authenticate` must be a function.');
13
+ if (authenticate === undefined && timeoutMs !== undefined) throw new TypeError('authenticationTimeoutMs requires authenticate.');
14
+ this.authenticate = authenticate;
15
+ this.boundary = new BoundedOperation(timeoutMs);
16
+ }
17
+
18
+ async resolve(request, signal) {
19
+ if (!this.authenticate) return undefined;
20
+ try {
21
+ const principal = await this.boundary.run(() => this.authenticate(request), signal);
22
+ if (!isPrincipal(principal)) throw new AuthenticationFailure();
23
+ return principal;
24
+ } catch (error) {
25
+ if (error instanceof AuthenticationFailure) throw new AuthenticationFailure(error.code);
26
+ if (error instanceof OperationInterrupted) throw new AuthenticationFailure(error.reason === 'timeout' ? 'AUTHENTICATION_TIMEOUT' : 'AUTHENTICATION_CANCELLED');
27
+ throw new AuthenticationFailure('AUTHENTICATION_FAILED');
28
+ }
29
+ }
30
+ }
31
+
32
+ module.exports = { PageIdentity, AuthenticationFailure, isPrincipal };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ const { AccessDenied } = require('../access/AccessPolicy');
3
+
4
+ /** Cancellation shared by HTTP work, reconnectable sessions, and individual connections. */
5
+ class PageLifetime {
6
+ constructor(parent) {
7
+ this.controller = new AbortController();
8
+ this.parent = parent;
9
+ this.abort = () => {
10
+ this.revoked = true;
11
+ this.controller.abort();
12
+ parent.removeEventListener('abort', this.abort);
13
+ };
14
+ parent.addEventListener('abort', this.abort, { once: true });
15
+ if (parent.aborted) this.abort();
16
+ }
17
+
18
+ get signal() { return this.controller.signal; }
19
+ check() { if (this.revoked) throw new AccessDenied('ACCESS_CANCELLED'); }
20
+
21
+ async wait(operation) {
22
+ this.check();
23
+ let abort;
24
+ try {
25
+ const cancelled = new Promise((_, reject) => {
26
+ abort = () => reject(new AccessDenied('ACCESS_CANCELLED'));
27
+ this.signal.addEventListener('abort', abort, { once: true });
28
+ });
29
+ const work = Promise.resolve().then(() => { this.check(); return operation(); });
30
+ const result = await Promise.race([work, cancelled]);
31
+ this.check();
32
+ return result;
33
+ } finally { this.signal.removeEventListener('abort', abort); }
34
+ }
35
+ }
36
+
37
+ module.exports = PageLifetime;