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,241 @@
1
+ 'use strict';
2
+
3
+ const { AsyncLocalStorage } = require('async_hooks');
4
+
5
+ const capture = new AsyncLocalStorage();
6
+ const ROOT = 'root';
7
+ const MAX_OWNERS = 1024;
8
+ const MAX_HTML_BYTES = 1024 * 1024;
9
+
10
+ function ownerId(component) {
11
+ return component ? `c${Buffer.from(component).toString('hex')}` : ROOT;
12
+ }
13
+
14
+ function boundary(id, html) { return `<!--rw:${id}-->${html}<!--/rw:${id}-->`; }
15
+
16
+ function replaceBoundary(source, id, html) {
17
+ const start = `<!--rw:${id}-->`;
18
+ const end = `<!--/rw:${id}-->`;
19
+ return source.split(start).map((part, index) => index ? html + part.slice(part.indexOf(end)) : part).join(start);
20
+ }
21
+
22
+ /** One rendering history per HTTP/page session, including shared pages. Never shares request context. */
23
+ class ReactiveRenderer {
24
+ constructor(page, signal, timeoutMs = 5000) {
25
+ this.page = page;
26
+ this.nodes = new Map();
27
+ this.dirty = new Set();
28
+ this.states = new Map();
29
+ this.socket = null;
30
+ this.pending = null;
31
+ this.running = null;
32
+ this.generation = 0;
33
+ this.disposed = false;
34
+ this.enabled = false;
35
+ this.controller = new AbortController();
36
+ this.connection = new AbortController();
37
+ this.timeoutMs = timeoutMs;
38
+ this.parentSignal = signal;
39
+ this.abort = () => this.dispose();
40
+ signal.addEventListener('abort', this.abort, { once: true });
41
+ if (signal.aborted) this.dispose();
42
+ }
43
+
44
+ static read(owner, name) {
45
+ const current = capture.getStore();
46
+ if (!current) return;
47
+ const names = current.node.dependencies.get(owner) || new Set();
48
+ names.add(name);
49
+ current.node.dependencies.set(owner, names);
50
+ }
51
+
52
+ static assertWritable() {
53
+ if (capture.getStore()) throw new Error('State cannot be modified during render().');
54
+ }
55
+
56
+ static jsx() {
57
+ const current = capture.getStore();
58
+ if (current) current.node.jsx = true;
59
+ return Boolean(current);
60
+ }
61
+
62
+ static key(html, value) {
63
+ if (!capture.getStore() || value === undefined || value === null) return html;
64
+ if (!['string', 'number'].includes(typeof value)) throw new TypeError('JSX keys must be strings or numbers.');
65
+ const key = String(value);
66
+ if (key.length > 256) throw new TypeError('JSX keys must be at most 256 characters.');
67
+ return boundary(`k${Buffer.from(key).toString('hex')}`, html);
68
+ }
69
+
70
+ static component(owner, component, render) {
71
+ const current = capture.getStore();
72
+ if (!current) return render();
73
+ const id = ownerId(component);
74
+ current.node.children.add(id);
75
+ const cached = current.stage.get(id);
76
+ if (cached) return boundary(id, cached.html);
77
+ const node = current.renderer.node(owner, id, render);
78
+ const html = capture.run({ ...current, node }, render);
79
+ current.renderer.finish(node, html, current.stage);
80
+ return boundary(id, html);
81
+ }
82
+
83
+ node(owner, id, render) {
84
+ return { owner, id, render, html: '', dependencies: new Map(), children: new Set(), jsx: false };
85
+ }
86
+
87
+ finish(node, html, stage) {
88
+ const retainedBytes = [...stage.values()].reduce((total, entry) => total + Buffer.byteLength(entry.html), 0);
89
+ if (retainedBytes + Buffer.byteLength(html) > MAX_HTML_BYTES) throw new Error('Reactive HTML exceeds the 1 MiB snapshot limit.');
90
+ if (stage.size >= MAX_OWNERS) throw new Error('Reactive render exceeds the 1024 owner limit.');
91
+ node.html = html;
92
+ stage.set(node.id, node);
93
+ }
94
+
95
+ async renderNode(previous, signal = this.controller.signal) {
96
+ const stage = new Map();
97
+ const node = this.node(previous.owner, previous.id, previous.render);
98
+ const work = Promise.resolve().then(() => this.withContext(() => capture.run({ renderer: this, node, stage }, node.render)));
99
+ let timer;
100
+ let abort;
101
+ const cancelled = new Promise((_, reject) => {
102
+ abort = () => reject(new Error('Reactive render was cancelled: page disconnected or server shutting down.'));
103
+ signal.addEventListener('abort', abort, { once: true });
104
+ if (signal.aborted) abort();
105
+ timer = setTimeout(() => reject(new Error('Reactive render exceeded its time limit.')), this.timeoutMs);
106
+ });
107
+ const html = await Promise.race([work, cancelled]).finally(() => {
108
+ clearTimeout(timer);
109
+ signal.removeEventListener('abort', abort);
110
+ });
111
+ this.finish(node, html, stage);
112
+ return stage;
113
+ }
114
+
115
+ async initialize(render, withContext) {
116
+ this.withContext = withContext;
117
+ const stage = await this.renderNode(this.node(this.page, ROOT, render));
118
+ if (this.disposed) throw new Error('Reactive render was cancelled.');
119
+ this.nodes = stage;
120
+ this.enabled = [...stage.values()].some(node => node.jsx);
121
+ const html = stage.get(ROOT).html;
122
+ return this.enabled ? html : html.replace(/<!--\/?rw:c[0-9a-f]+-->/g, '');
123
+ }
124
+
125
+ descendants(id, target = new Set()) {
126
+ target.add(id);
127
+ for (const child of this.nodes.get(id).children) this.descendants(child, target);
128
+ return target;
129
+ }
130
+
131
+ commit(id, stage) {
132
+ const replaced = this.descendants(id);
133
+ if (this.nodes.size - replaced.size + stage.size > MAX_OWNERS) throw new Error('Reactive render exceeds the 1024 owner limit.');
134
+ const next = new Map([...this.nodes].filter(([key]) => !replaced.has(key)));
135
+ for (const [key, value] of stage) next.set(key, value);
136
+ if (id !== ROOT) {
137
+ const html = stage.get(id).html;
138
+ for (const [key, node] of next) if (!stage.has(key)) next.set(key, { ...node, html: replaceBoundary(node.html, id, html) });
139
+ }
140
+ const retainedBytes = [...next.values()].reduce((total, entry) => total + Buffer.byteLength(entry.html), 0);
141
+ if (retainedBytes > MAX_HTML_BYTES) throw new Error('Reactive HTML exceeds the 1 MiB snapshot limit.');
142
+ this.nodes = next;
143
+ }
144
+
145
+ invalidate(owner, name, payload) {
146
+ if (this.disposed) return;
147
+ for (const [id, node] of this.nodes) if (node.dependencies.get(owner)?.has(name)) this.dirty.add(id);
148
+ this.states.set(`${ownerId(payload.component)}:${name}`, payload);
149
+ this.schedule();
150
+ }
151
+
152
+ schedule() {
153
+ if (!this.socket || this.pending || this.running || this.disposed) return;
154
+ this.pending = setImmediate(() => {
155
+ this.pending = null;
156
+ this.flush().catch(error => {
157
+ // A failing application logger must not turn a contained render error into an unhandled rejection.
158
+ Promise.resolve().then(() => this.onError(error)).catch(() => {});
159
+ this.socket?.close(1011, 'Page render failed');
160
+ });
161
+ });
162
+ }
163
+
164
+ async attach(socket, states) {
165
+ this.socket = socket;
166
+ this.connection = new AbortController();
167
+ this.generation += 1;
168
+ for (const payload of states) this.states.set(`${ownerId(payload.component)}:${payload.name}`, payload);
169
+ this.dirty.add(ROOT);
170
+ this.snapshot = true;
171
+ return this.flush();
172
+ }
173
+
174
+ detach() {
175
+ this.socket = null;
176
+ this.connection.abort();
177
+ this.generation += 1;
178
+ clearImmediate(this.pending);
179
+ this.pending = null;
180
+ this.dirty.clear();
181
+ this.states.clear();
182
+ }
183
+
184
+ flush() {
185
+ if (this.running) return this.running;
186
+ this.running = this.performFlush().finally(() => {
187
+ this.running = null;
188
+ if (this.dirty.size || this.states.size) this.schedule();
189
+ });
190
+ return this.running;
191
+ }
192
+
193
+ async performFlush() {
194
+ const socket = this.socket;
195
+ const generation = this.generation;
196
+ const dirty = [...this.dirty];
197
+ const states = [...this.states.values()];
198
+ const snapshot = this.snapshot;
199
+ this.snapshot = false;
200
+ this.dirty.clear();
201
+ this.states.clear();
202
+ const covered = new Set();
203
+ const patches = [];
204
+ // Parents must be committed before descendants, regardless of the order their state changed.
205
+ dirty.sort((a, b) => a === ROOT ? -1 : b === ROOT ? 1 : a.length - b.length);
206
+ for (const id of dirty) {
207
+ if (covered.has(id) || !this.nodes.has(id)) continue;
208
+ for (const child of this.descendants(id)) covered.add(child);
209
+ const previous = this.nodes.get(id);
210
+ let stage;
211
+ try { stage = await this.renderNode(previous, this.connection.signal); }
212
+ catch (error) {
213
+ if (this.disposed || generation !== this.generation) return;
214
+ throw error;
215
+ }
216
+ if (this.disposed || generation !== this.generation) return;
217
+ const html = stage.get(id).html;
218
+ this.commit(id, stage);
219
+ if (snapshot || html !== previous.html) patches.push({ id, html: id === ROOT ? this.document(html) : html });
220
+ }
221
+ const explicit = states.filter(payload => {
222
+ const node = this.nodes.get(ownerId(payload.component));
223
+ return node && [...node.html.matchAll(/\b(?:data-rw-state|rw-bind)\s*=\s*["']([^"']+)["']/g)].some(match => match[1] === payload.name);
224
+ });
225
+ if (!this.disposed && generation === this.generation && socket && (patches.length || explicit.length)) {
226
+ socket.sendEvent('redweb:patch', { patches, states: explicit });
227
+ }
228
+ }
229
+
230
+ dispose() {
231
+ this.disposed = true;
232
+ this.detach();
233
+ this.controller.abort();
234
+ this.parentSignal.removeEventListener('abort', this.abort);
235
+ this.nodes.clear();
236
+ this.withContext = null;
237
+ this.page = null;
238
+ }
239
+ }
240
+
241
+ module.exports = ReactiveRenderer;
@@ -36,6 +36,7 @@ async function exportStatic(pageOrPages, options = {}) {
36
36
  const metadata = getPageMetadata(PageClass);
37
37
  if (!metadata) throw new TypeError(`${PageClass.name || 'Page'} is missing @page metadata.`);
38
38
  if (metadata.live !== false) throw new Error(`Static export requires live: false on ${PageClass.name}.`);
39
+ if (metadata.policy) throw new Error(`Authorized pages cannot be exported: ${PageClass.name}.`);
39
40
  const file = pageFile(root, metadata.path);
40
41
  const key = path.relative(root, file).replaceAll('\\', '/').toLowerCase();
41
42
  if (pageFiles.has(key)) throw new Error(`Static page paths resolve to the same output file: ${file}`);
@@ -60,7 +61,6 @@ async function exportStatic(pageOrPages, options = {}) {
60
61
  params: Object.freeze({}),
61
62
  query: Object.freeze({}),
62
63
  body: undefined,
63
- get: () => undefined,
64
64
  });
65
65
  renderedPages.push({ file, content: await manager.render(record, request) });
66
66
  }
@@ -13,7 +13,7 @@ const BINDING = /{{\s*([A-Za-z_$][\w$]*)\s*}}/g;
13
13
  const ATTRIBUTE_BINDING = /{{\s*[A-Za-z_$][\w$]*\s*}}/;
14
14
  const NAME = /^[A-Za-z_$][\w$]*$/;
15
15
  const DIRECTIVES = new Set(['data-rw-state', 'data-rw-html', 'rw-each']);
16
- const COMPONENT_DIRECTIVES = new Set(['data-rw-component', 'data-rw-state', 'rw-bind', 'rw-click', 'rw-submit']);
16
+ const COMPONENT_DIRECTIVES = new Set(['data-rw-component', 'data-rw-state', 'rw-bind', 'rw-click', 'rw-submit', 'rw-status']);
17
17
 
18
18
  function attributes(tag, nameEnd, tracked = DIRECTIVES) {
19
19
  const found = new Map();
@@ -181,7 +181,8 @@ class TemplateRenderer {
181
181
 
182
182
  TemplateRenderer.closingTag = closingTag;
183
183
  TemplateRenderer.openingTag = openingTag;
184
- TemplateRenderer.component = (source, id) => {
184
+ // Shared lexical traversal: component scoping and read-only diagnostics see the same tags.
185
+ TemplateRenderer.mapTags = (source, transform) => {
185
186
  let output = '';
186
187
  let position = 0;
187
188
  while (position < source.length) {
@@ -211,11 +212,7 @@ TemplateRenderer.component = (source, id) => {
211
212
  const end = tagEnd(source, start + opening[0].length);
212
213
  if (end < 0) return output + source.slice(start);
213
214
  const tag = source.slice(start, end + 1);
214
- const found = attributes(tag, opening[0].length, COMPONENT_DIRECTIVES);
215
- const scoped = [...COMPONENT_DIRECTIVES].some(name => name !== 'data-rw-component' && found.has(name));
216
- output += scoped && !found.has('data-rw-component')
217
- ? tag.replace(/\/?>(?=$)/, ` data-rw-component="${escapeHtml(id)}"$&`)
218
- : tag;
215
+ output += transform(tag, opening[0].length, start);
219
216
  position = end + 1;
220
217
  const name = opening[1].toLowerCase();
221
218
  if (RAW_TEXT.has(name)) {
@@ -228,4 +225,13 @@ TemplateRenderer.component = (source, id) => {
228
225
  return output;
229
226
  };
230
227
 
228
+ TemplateRenderer.attributes = attributes;
229
+ TemplateRenderer.component = (source, id) => TemplateRenderer.mapTags(source, (tag, nameEnd) => {
230
+ const found = attributes(tag, nameEnd, COMPONENT_DIRECTIVES);
231
+ const scoped = [...COMPONENT_DIRECTIVES].some(name => name !== 'data-rw-component' && found.has(name));
232
+ return scoped && !found.has('data-rw-component')
233
+ ? tag.replace(/\/?>(?=$)/, ` data-rw-component="${escapeHtml(id)}"$&`)
234
+ : tag;
235
+ });
236
+
231
237
  module.exports = TemplateRenderer;
@@ -1,97 +1,6 @@
1
+ /** The client owns rendering and transport; the server supplies page JSON. */
1
2
  function browserRuntime(clientPath) {
2
- return `import { RedwebClient } from ${JSON.stringify(clientPath)};
3
-
4
- const configNode = document.getElementById('__redweb_page');
5
- const config = JSON.parse(configNode.textContent);
6
- const client = new RedwebClient(config.socketPath + '?pageId=' + encodeURIComponent(config.pageId), {
7
- baseUrl: window.location.href,
8
- version: config.version,
9
- reconnect: { enabled: true, maxAttempts: 8 },
10
- maxQueueSize: 32
11
- });
12
-
13
- const emit = (type, detail) => document.dispatchEvent(new CustomEvent(type, { detail }));
14
- let stateTargets = new Map();
15
- const componentOf = node => node.closest('[data-rw-component]')?.getAttribute('data-rw-component') || null;
16
- const stateKey = (component, name) => (component || '') + '\\0' + name;
17
- const indexState = () => {
18
- stateTargets = new Map();
19
- document.querySelectorAll('[data-rw-state]').forEach(node => {
20
- const name = node.getAttribute('data-rw-state');
21
- const key = stateKey(componentOf(node), name);
22
- const targets = stateTargets.get(key) || [];
23
- targets.push(node);
24
- stateTargets.set(key, targets);
25
- });
26
- };
27
- const named = (attribute, name, component) => attribute === 'data-rw-state'
28
- ? (stateTargets.get(stateKey(component, name)) || [])
29
- : [...document.querySelectorAll('[' + attribute + ']')].filter(node =>
30
- node.getAttribute(attribute) === name && componentOf(node) === component);
31
- indexState();
32
-
33
- client.on('redweb:state', message => {
34
- const update = message.payload;
35
- const component = update.component || null;
36
- named('data-rw-state', update.name, component).forEach(node => {
37
- if (update.html) {
38
- node.innerHTML = update.value;
39
- indexState();
40
- }
41
- else node.textContent = update.value;
42
- });
43
- named('rw-bind', update.name, component).forEach(node => {
44
- if (node.type === 'checkbox') node.checked = update.value === true || update.value === 'true';
45
- else if (node.value !== update.value) node.value = update.value;
46
- });
47
- });
48
-
49
- const report = error => emit('redweb:error', error);
50
- const send = payload => {
51
- try { client.send('redweb:html', payload); }
52
- catch (error) { report(error); }
53
- };
54
- const formValues = form => {
55
- const values = {};
56
- for (const [name, value] of new FormData(form)) {
57
- if (!(name in values)) values[name] = value;
58
- else values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
59
- }
60
- return values;
61
- };
62
-
63
- document.addEventListener('click', event => {
64
- const target = event.target.closest('[rw-click]');
65
- if (!target) return;
66
- event.preventDefault();
67
- client.request('redweb:html', {
68
- kind: 'action', name: target.getAttribute('rw-click'), component: componentOf(target), args: []
69
- }).catch(report);
70
- });
71
-
72
- document.addEventListener('submit', event => {
73
- const form = event.target.closest('form[rw-submit]');
74
- if (!form) return;
75
- event.preventDefault();
76
- client.request('redweb:html', {
77
- kind: 'action', name: form.getAttribute('rw-submit'), component: componentOf(form), args: [formValues(form)]
78
- }).then(() => form.reset()).catch(report);
79
- });
80
-
81
- document.addEventListener('input', event => {
82
- const target = event.target.closest('[rw-bind]');
83
- if (target) send({
84
- kind: 'state',
85
- name: target.getAttribute('rw-bind'),
86
- component: componentOf(target),
87
- value: target.type === 'checkbox' ? target.checked : target.value
88
- });
89
- });
90
-
91
- client.onError(report);
92
- client.onStateChange(state => emit('redweb:connection', state));
93
- client.connect().catch(report);
94
- `;
3
+ return `import { mountLivePage } from ${JSON.stringify(clientPath)};\nmountLivePage();\n`;
95
4
  }
96
5
 
97
6
  module.exports = browserRuntime;
@@ -12,6 +12,9 @@ const PAGE_STYLESHEET_ROOTS = new WeakMap();
12
12
  const COMPONENT_CLASSES = new WeakSet();
13
13
  const { decoratorDirectory } = require('./sourceRoot');
14
14
  const synchronous = require('./synchronous');
15
+ const { ActionDefinition } = require('./ActionDefinition');
16
+ const { AccessPolicy } = require('../access/AccessPolicy');
17
+ const dataProperty = require('../dataProperty');
15
18
  let metadataVersion = 0;
16
19
 
17
20
  function assertDecoratorTarget(target, label) {
@@ -40,7 +43,6 @@ function registerState(PageClass, property, config) {
40
43
 
41
44
  function registerAction(PageClass, method, implementation) {
42
45
  const methods = new Map(ACTION_METADATA.get(PageClass) || []);
43
- if (methods.get(method) === implementation) return;
44
46
  methods.set(method, implementation);
45
47
  ACTION_METADATA.set(PageClass, methods);
46
48
  metadataVersion += 1;
@@ -80,8 +82,8 @@ function resolvedAction(PageClass) {
80
82
  const value = new Map();
81
83
  hierarchy(PageClass).forEach(CurrentClass => {
82
84
  const own = new Map(ACTION_METADATA.get(CurrentClass) || []);
83
- STANDARD_ACTIONS.get(CurrentClass)?.forEach((implementation, method) => {
84
- if (CurrentClass.prototype[method] === implementation) own.set(method, implementation);
85
+ STANDARD_ACTIONS.get(CurrentClass)?.forEach((entry, method) => {
86
+ if (dataProperty(CurrentClass.prototype, method) === entry.implementation) own.set(method, entry);
85
87
  });
86
88
  value.forEach((_implementation, method) => {
87
89
  if (Object.prototype.hasOwnProperty.call(CurrentClass.prototype, method) && !own.has(method)) value.delete(method);
@@ -180,6 +182,8 @@ function page(routePath, options = {}) {
180
182
  if (layout !== undefined && typeof layout !== 'function') throw new TypeError('Page layout must be a function.');
181
183
  const head = pageHead(options.head);
182
184
  const cache = pageCache(options.cache, live);
185
+ const policy = new AccessPolicy(options.authorize, options.authorizationTimeoutMs);
186
+ if (policy.authorize && scope === 'shared') throw new TypeError('Authorized pages require connection scope; shared state is not private.');
183
187
  return PageClass => {
184
188
  if (typeof PageClass !== 'function') throw new TypeError('page() must decorate a class.');
185
189
  PAGE_METADATA.set(PageClass, Object.freeze({
@@ -191,6 +195,7 @@ function page(routePath, options = {}) {
191
195
  ...(cache && { cache }),
192
196
  ...(stylesheets && { css: Object.freeze(stylesheets) }),
193
197
  ...(layout && { layout }),
198
+ ...(policy.authorize && { policy }),
194
199
  }));
195
200
  PAGE_ROOTS.set(PageClass, templateRoot);
196
201
  return PageClass;
@@ -241,14 +246,16 @@ function state(options = {}) {
241
246
  };
242
247
  }
243
248
 
244
- function action() {
249
+ function action(options) {
250
+ const definition = new ActionDefinition(options);
245
251
  return (target, method, descriptor) => {
246
252
  if (method?.kind === 'method') {
247
253
  if (method.static || method.private || typeof method.name !== 'string' || !method.name || typeof target !== 'function') {
248
254
  throw new TypeError('action() requires a public instance method with a string name.');
249
255
  }
256
+ const entry = Object.freeze({ implementation: target, definition });
250
257
  method.addInitializer(function registerStandardActionInitializer() {
251
- if (this[method.name] === target) registerStandardAction(this.constructor, method.name, target);
258
+ if (this[method.name] === target) registerStandardAction(this.constructor, method.name, entry);
252
259
  });
253
260
  return target;
254
261
  }
@@ -256,7 +263,7 @@ function action() {
256
263
  if (typeof method !== 'string' || !method || typeof descriptor?.value !== 'function') {
257
264
  throw new TypeError('action() must decorate a method.');
258
265
  }
259
- registerAction(PageClass, method, descriptor.value);
266
+ registerAction(PageClass, method, Object.freeze({ implementation: descriptor.value, definition }));
260
267
  return descriptor;
261
268
  };
262
269
  }
@@ -315,7 +322,11 @@ function forEachState(PageClass, callback) {
315
322
  }
316
323
 
317
324
  function getActionImplementation(PageClass, method) {
318
- return resolvedAction(PageClass).get(method);
325
+ return resolvedAction(PageClass).get(method)?.implementation;
326
+ }
327
+
328
+ function getActionDefinition(PageClass, method) {
329
+ return resolvedAction(PageClass).get(method).definition;
319
330
  }
320
331
 
321
332
  function getViewImplementation(PageClass, stateName) {
@@ -331,6 +342,7 @@ module.exports = {
331
342
  component,
332
343
  forEachState,
333
344
  getActionImplementation,
345
+ getActionDefinition,
334
346
  getActionMetadata,
335
347
  getPageMetadata,
336
348
  getPageStylesheetRoots,
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+ const codes = require('../access/failure-codes.json');
3
+
4
+ class ActionInputError extends Error {
5
+ constructor(code = 'ACTION_INVALID_INPUT') {
6
+ const safeCode = typeof code === 'string' && code.startsWith('ACTION_') && Object.hasOwn(codes, code) ? code : 'ACTION_INVALID_INPUT';
7
+ super(codes[safeCode].message);
8
+ this.code = safeCode;
9
+ }
10
+ }
11
+
12
+ module.exports = ActionInputError;
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+ const { BoundedOperation, OperationInterrupted } = require('../async/BoundedOperation');
3
+
4
+ class ValidationFailure extends Error {
5
+ constructor(reason) {
6
+ super('Schema validation did not complete successfully.');
7
+ this.reason = reason;
8
+ }
9
+ }
10
+
11
+ /** One bounded Standard Schema path for socket contracts and browser actions. */
12
+ class SchemaValidator {
13
+ constructor(schema, timeoutMs = 5000) {
14
+ const standard = schema?.['~standard'];
15
+ if (standard?.version !== 1 || typeof standard.validate !== 'function') throw new TypeError('A Standard Schema v1 validator is required.');
16
+ this.validateValue = standard.validate.bind(standard);
17
+ this.boundary = new BoundedOperation(timeoutMs);
18
+ Object.freeze(this);
19
+ }
20
+
21
+ async parse(input, signal) {
22
+ try {
23
+ return await this.boundary.run(async () => {
24
+ const result = await this.validateValue(input);
25
+ if (!result || typeof result !== 'object' || Array.isArray(result)) throw new ValidationFailure('validator');
26
+ if (result.issues !== undefined) throw new ValidationFailure(Array.isArray(result.issues) ? 'input' : 'validator');
27
+ if (!Object.hasOwn(result, 'value')) throw new ValidationFailure('validator');
28
+ return await result.value;
29
+ }, signal);
30
+ } catch (error) {
31
+ if (error instanceof ValidationFailure) throw error;
32
+ if (error instanceof OperationInterrupted) throw new ValidationFailure(error.reason);
33
+ throw new ValidationFailure('validator');
34
+ }
35
+ }
36
+ }
37
+
38
+ module.exports = { SchemaValidator, ValidationFailure };
@@ -1,6 +1,8 @@
1
1
  const ADMISSION_CONTEXT = Symbol('redweb.admissionContext');
2
2
  const PLACEMENT_REDIRECT = Symbol('redweb.placementRedirect');
3
3
  const ADMISSION_SETTLEMENT = Symbol('redweb.admissionSettlement');
4
+ const { BoundedOperation, OperationInterrupted } = require('../async/BoundedOperation');
5
+ const { RequestFailure, UPGRADE_REJECTION } = require('../access/RequestFailure');
4
6
 
5
7
  class AdmissionPolicy {
6
8
  constructor(options) {
@@ -48,41 +50,37 @@ class AdmissionPolicy {
48
50
  this.origins = origins;
49
51
  this.place = place;
50
52
  this.timeoutMs = timeoutMs;
53
+ this.boundary = new BoundedOperation(timeoutMs);
51
54
  this.allowInsecurePlacement = allowInsecurePlacement;
52
55
  }
53
56
 
54
57
  async authorize(request, rawSocket, route, externalSignal) {
55
58
  const controller = new AbortController();
56
59
  const onClose = () => controller.abort();
57
- const onExternalAbort = () => controller.abort();
58
60
  rawSocket.once('close', onClose);
59
- if (externalSignal?.aborted) controller.abort();
60
- else externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
61
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
62
- timer.unref();
61
+ if (rawSocket.destroyed || externalSignal?.aborted) controller.abort();
62
+ else externalSignal?.addEventListener('abort', onClose, { once: true });
63
63
  try {
64
- const evaluation = Promise.resolve().then(() => this.evaluate(request, route, controller.signal));
65
- request[ADMISSION_SETTLEMENT] = evaluation.then(() => undefined, () => undefined);
66
- const cancelled = new Promise((_, reject) => {
67
- controller.signal.addEventListener('abort', () => reject(new Error('Admission cancelled.')), { once: true });
68
- });
69
- const result = await Promise.race([
70
- evaluation,
71
- cancelled,
72
- ]);
73
- if (result === false || rawSocket.destroyed || controller.signal.aborted) return false;
64
+ const result = await this.boundary.run((signal, checkpoint) => {
65
+ const evaluation = this.evaluate(request, route, signal, checkpoint);
66
+ request[ADMISSION_SETTLEMENT] = evaluation.then(() => undefined, () => undefined);
67
+ return evaluation;
68
+ }, controller.signal);
69
+ if (rawSocket.destroyed || controller.signal.aborted) throw new RequestFailure('ADMISSION_CANCELLED');
74
70
  if (result.redirect) {
75
71
  request[PLACEMENT_REDIRECT] = result.redirect;
76
72
  return false;
77
73
  }
78
74
  request[ADMISSION_CONTEXT] = { principal: result.principal };
79
75
  return true;
80
- } catch {
76
+ } catch (error) {
77
+ request[UPGRADE_REJECTION] = (error instanceof OperationInterrupted
78
+ ? new RequestFailure(error.reason === 'timeout' ? 'ADMISSION_TIMEOUT' : 'ADMISSION_CANCELLED')
79
+ : RequestFailure.from(error)).rejection;
81
80
  return false;
82
81
  } finally {
83
- clearTimeout(timer);
84
82
  rawSocket.off?.('close', onClose);
85
- externalSignal?.removeEventListener?.('abort', onExternalAbort);
83
+ externalSignal?.removeEventListener?.('abort', onClose);
86
84
  }
87
85
  }
88
86
 
@@ -114,20 +112,23 @@ class AdmissionPolicy {
114
112
  }
115
113
  }
116
114
 
117
- async evaluate(request, route, signal) {
118
- if (!await this.acceptsOrigin(request)) return false;
115
+ async evaluate(request, route, signal, checkpoint) {
116
+ if (!await this.acceptsOrigin(request)) throw new RequestFailure('ORIGIN_DENIED');
117
+ checkpoint();
119
118
  const context = {
120
119
  signal,
121
120
  networkIdentity: route.resolveRemoteAddress(request),
122
121
  route,
123
122
  };
124
123
  const principal = this.authenticate ? await this.authenticate(request, context) : undefined;
125
- if (principal === false) return false;
124
+ if (principal === false) throw new RequestFailure('AUTHENTICATION_REQUIRED');
125
+ checkpoint();
126
126
  if (!this.place) return { principal };
127
127
  const placement = await this.place(principal, request, context);
128
- if (placement === false) return false;
128
+ if (placement === false) throw new RequestFailure('PLACEMENT_DENIED');
129
129
  if (typeof placement === 'string') {
130
- return this.isSafeRedirect(placement) ? { redirect: placement } : false;
130
+ if (!this.isSafeRedirect(placement)) throw new RequestFailure('PLACEMENT_INVALID');
131
+ return { redirect: placement };
131
132
  }
132
133
  return { principal };
133
134
  }