redweb 0.16.2 → 0.16.4

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 (71) hide show
  1. package/CHANGELOG.md +40 -29
  2. package/README.md +293 -291
  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 -122
  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 -78
  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 +2290 -2286
  20. package/docs/guides/chatroom.md +1 -1
  21. package/docs/guides/jsx-without-react.md +14 -14
  22. package/docs/reference.json +1333 -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 -2286
  27. package/docs/releases/0.16.3.json +2286 -0
  28. package/docs/releases/0.16.4.json +2290 -0
  29. package/docs/snippets/components.tsx +24 -24
  30. package/docs/snippets/counter.tsx +16 -16
  31. package/docs/snippets/room-access.tsx +11 -11
  32. package/docs/snippets/site.css +2 -2
  33. package/docs/snippets/site.tsx +22 -22
  34. package/docs/topics.json +3 -3
  35. package/index.d.ts +96 -58
  36. package/index.js +14 -8
  37. package/package.json +12 -8
  38. package/recipes/foundation/README.md +7 -7
  39. package/recipes/foundation/app.test.cjs +15 -15
  40. package/recipes/foundation/app.tsx +12 -12
  41. package/recipes/shared/README.md +7 -7
  42. package/src/Application.js +4 -4
  43. package/src/access/failure-codes.json +4 -0
  44. package/src/cli/ProjectInitializer.js +1 -1
  45. package/src/cli/arguments.js +21 -21
  46. package/src/cli/run.js +12 -12
  47. package/src/cli/templates.js +40 -40
  48. package/src/docs/Documentation.js +29 -29
  49. package/src/htmx/CodeHighlight.js +98 -0
  50. package/src/htmx/Html.js +4 -3
  51. package/src/htmx/Jsx.js +2 -2
  52. package/src/htmx/LiveHtmlServer.js +5 -1
  53. package/src/htmx/LivePage.js +5 -1
  54. package/src/htmx/LiveResource.js +96 -0
  55. package/src/htmx/PageManager.js +126 -13
  56. package/src/htmx/PageSocketRoute.js +132 -132
  57. package/src/htmx/PageTaskLane.js +39 -0
  58. package/src/htmx/ReactiveRenderer.js +8 -8
  59. package/src/htmx/SocketAction.js +19 -19
  60. package/src/htmx/TemplateRenderer.js +1 -1
  61. package/src/htmx/index.js +4 -2
  62. package/src/htmx/metadata.js +117 -6
  63. package/src/ws/BaseHandler.js +6 -6
  64. package/src/ws/ConnectedClients.js +207 -207
  65. package/src/ws/HandlerGuard.js +4 -4
  66. package/src/ws/RoomRegistry.js +4 -4
  67. package/src/ws/RouteRuntime.js +11 -11
  68. package/src/ws/SocketAction.js +16 -16
  69. package/src/ws/SocketContract.js +3 -3
  70. package/src/ws/SocketRoute.js +7 -7
  71. package/styles/code-highlight.css +16 -0
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ /** Serializes all browser work for one live page, regardless of transport. */
4
+ class PageTaskLane {
5
+ constructor(maxPending = 64) {
6
+ this.maxPending = maxPending;
7
+ this.tasks = [];
8
+ this.running = false;
9
+ this.closed = false;
10
+ }
11
+
12
+ enqueue(task) {
13
+ if (typeof task !== 'function') throw new TypeError('Page tasks must be functions.');
14
+ if (this.closed || this.tasks.length + (this.running ? 1 : 0) >= this.maxPending) return null;
15
+ let resolve, reject;
16
+ const result = new Promise((accept, deny) => { resolve = accept; reject = deny; });
17
+ this.tasks.push({ task, resolve, reject });
18
+ if (!this.running) void this.drain();
19
+ return result;
20
+ }
21
+
22
+ async drain() {
23
+ this.running = true;
24
+ while (!this.closed && this.tasks.length) {
25
+ const current = this.tasks.shift();
26
+ try { current.resolve(await current.task()); }
27
+ catch (error) { current.reject(error); }
28
+ }
29
+ this.running = false;
30
+ }
31
+
32
+ close(error = new Error('Page task lane closed.')) {
33
+ if (this.closed) return;
34
+ this.closed = true;
35
+ this.tasks.splice(0).forEach(task => task.reject(error));
36
+ }
37
+ }
38
+
39
+ module.exports = PageTaskLane;
@@ -222,14 +222,14 @@ class ReactiveRenderer {
222
222
  const node = this.nodes.get(ownerId(payload.component));
223
223
  return node && [...node.html.matchAll(/\b(?:data-rw-state|rw-bind)\s*=\s*["']([^"']+)["']/g)].some(match => match[1] === payload.name);
224
224
  });
225
- if (!this.disposed && generation === this.generation && socket && (patches.length || explicit.length)) {
226
- try { if (this.authorize) await this.authorize(); }
227
- catch (error) {
228
- if (this.disposed || generation !== this.generation) return;
229
- throw error;
230
- }
231
- if (this.disposed || generation !== this.generation) return;
232
- socket.sendEvent('redweb:patch', { patches, states: explicit });
225
+ if (!this.disposed && generation === this.generation && socket && (patches.length || explicit.length)) {
226
+ try { if (this.authorize) await this.authorize(); }
227
+ catch (error) {
228
+ if (this.disposed || generation !== this.generation) return;
229
+ throw error;
230
+ }
231
+ if (this.disposed || generation !== this.generation) return;
232
+ socket.sendEvent('redweb:patch', { patches, states: explicit });
233
233
  }
234
234
  }
235
235
 
@@ -1,19 +1,19 @@
1
- 'use strict';
2
-
3
- const { AsyncLocalStorage } = require('async_hooks');
4
- const { bindings } = require('../ws/SocketAction');
5
- const routes = new AsyncLocalStorage();
6
-
7
- function attributes(properties) {
8
- let result = properties;
9
- for (const name of ['rw-click', 'rw-submit']) {
10
- const binding = bindings.get(properties[name]);
11
- if (!binding) continue;
12
- if (!routes.getStore()?.has(binding.Handler)) throw new TypeError('Socket action handler must belong to this page socket route.');
13
- if (Object.keys(result).some(key => key.toLowerCase() === 'data-rw-command')) throw new TypeError('Only one socket action may bind a control.');
14
- result = { ...result, [name]: binding.type, 'data-rw-command': JSON.stringify({ type: binding.type, payload: binding.payload }) };
15
- }
16
- return result;
17
- }
18
-
19
- module.exports = { attributes, withRoute: (handlers, render) => routes.run(handlers, render) };
1
+ 'use strict';
2
+
3
+ const { AsyncLocalStorage } = require('async_hooks');
4
+ const { bindings } = require('../ws/SocketAction');
5
+ const routes = new AsyncLocalStorage();
6
+
7
+ function attributes(properties) {
8
+ let result = properties;
9
+ for (const name of ['rw-click', 'rw-submit']) {
10
+ const binding = bindings.get(properties[name]);
11
+ if (!binding) continue;
12
+ if (!routes.getStore()?.has(binding.Handler)) throw new TypeError('Socket action handler must belong to this page socket route.');
13
+ if (Object.keys(result).some(key => key.toLowerCase() === 'data-rw-command')) throw new TypeError('Only one socket action may bind a control.');
14
+ result = { ...result, [name]: binding.type, 'data-rw-command': JSON.stringify({ type: binding.type, payload: binding.payload }) };
15
+ }
16
+ return result;
17
+ }
18
+
19
+ module.exports = { attributes, withRoute: (handlers, render) => routes.run(handlers, render) };
@@ -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', 'rw-status']);
16
+ const COMPONENT_DIRECTIVES = new Set(['data-rw-component', 'data-rw-state', 'rw-bind', 'rw-click', 'rw-submit', 'rw-upload', 'rw-paste', 'rw-status']);
17
17
 
18
18
  function attributes(tag, nameEnd, tracked = DIRECTIVES) {
19
19
  const found = new Map();
package/src/htmx/index.js CHANGED
@@ -2,9 +2,11 @@ const HtmlRenderer = require('./HtmlRenderer');
2
2
  const LiveHtmlServer = require('./LiveHtmlServer');
3
3
  const LivePage = require('./LivePage');
4
4
  const { attribute, codeBlock, each, html, safeUrl: url } = require('./Html');
5
- const { action, component, page, state, view } = require('./metadata');
5
+ const { highlightCode } = require('./CodeHighlight');
6
+ const { action, component, inject, page, resource, state, upload, view } = require('./metadata');
7
+ const { LiveResource, liveResource } = require('./LiveResource');
6
8
  const { start } = require('./start');
7
9
  const { exportStatic } = require('./StaticExporter');
8
10
  const { defineSite } = require('./StaticSite');
9
11
 
10
- module.exports = { action, attribute, codeBlock, component, defineSite, each, exportStatic, html, HtmlRenderer, LiveHtmlServer, LivePage, page, start, state, url, view };
12
+ module.exports = { action, attribute, codeBlock, component, defineSite, each, exportStatic, highlightCode, html, HtmlRenderer, inject, LiveHtmlServer, LivePage, LiveResource, liveResource, page, resource, start, state, upload, url, view };
@@ -7,6 +7,12 @@ const STANDARD_ACTIONS = new WeakMap();
7
7
  const VIEW_METADATA = new WeakMap();
8
8
  const RESOLVED_VIEW = new WeakMap();
9
9
  const STANDARD_VIEWS = new WeakMap();
10
+ const RESOURCE_METADATA = new WeakMap();
11
+ const RESOLVED_RESOURCE = new WeakMap();
12
+ const INJECT_METADATA = new WeakMap();
13
+ const RESOLVED_INJECT = new WeakMap();
14
+ const UPLOAD_METADATA = new WeakMap();
15
+ const RESOLVED_UPLOAD = new WeakMap();
10
16
  const PAGE_ROOTS = new WeakMap();
11
17
  const PAGE_STYLESHEET_ROOTS = new WeakMap();
12
18
  const COMPONENT_CLASSES = new WeakSet();
@@ -65,6 +71,27 @@ function registerView(store, PageClass, stateName, method, implementation) {
65
71
  metadataVersion += 1;
66
72
  }
67
73
 
74
+ function registerResource(PageClass, property, config) {
75
+ const properties = new Map(RESOURCE_METADATA.get(PageClass) || []);
76
+ properties.set(property, config);
77
+ RESOURCE_METADATA.set(PageClass, properties);
78
+ metadataVersion += 1;
79
+ }
80
+
81
+ function registerInject(PageClass, property, provider) {
82
+ const properties = new Map(INJECT_METADATA.get(PageClass) || []);
83
+ properties.set(property, provider);
84
+ INJECT_METADATA.set(PageClass, properties);
85
+ metadataVersion += 1;
86
+ }
87
+
88
+ function registerUpload(PageClass, method, config) {
89
+ const methods = new Map(UPLOAD_METADATA.get(PageClass) || []);
90
+ methods.set(method, config);
91
+ UPLOAD_METADATA.set(PageClass, methods);
92
+ metadataVersion += 1;
93
+ }
94
+
68
95
  function resolvedState(PageClass) {
69
96
  const cached = RESOLVED_STATE.get(PageClass);
70
97
  if (cached?.version === metadataVersion) return cached.value;
@@ -112,6 +139,15 @@ function resolvedView(PageClass) {
112
139
  return value;
113
140
  }
114
141
 
142
+ function resolved(store, cache, PageClass) {
143
+ const cached = cache.get(PageClass);
144
+ if (cached?.version === metadataVersion) return cached.value;
145
+ const value = new Map();
146
+ hierarchy(PageClass).forEach(CurrentClass => store.get(CurrentClass)?.forEach((config, property) => value.set(property, config)));
147
+ cache.set(PageClass, { version: metadataVersion, value });
148
+ return value;
149
+ }
150
+
115
151
  function pageHead(value) {
116
152
  if (value === undefined) return undefined;
117
153
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Page head must be an object.');
@@ -178,10 +214,10 @@ function page(routePath, options = {}) {
178
214
  if (!['connection', 'shared'].includes(scope)) {
179
215
  throw new TypeError('Page scope must be "connection" or "shared".');
180
216
  }
181
- if (typeof live !== 'boolean') throw new TypeError('Page live must be a boolean.');
182
- if (options.socket !== undefined && (typeof options.socket !== 'function' || !live || scope !== 'connection')) {
183
- throw new TypeError('Page socket requires a route class and a live connection-scoped page.');
184
- }
217
+ if (typeof live !== 'boolean') throw new TypeError('Page live must be a boolean.');
218
+ if (options.socket !== undefined && (typeof options.socket !== 'function' || !live || scope !== 'connection')) {
219
+ throw new TypeError('Page socket requires a route class and a live connection-scoped page.');
220
+ }
185
221
  if (layout !== undefined && typeof layout !== 'function') throw new TypeError('Page layout must be a function.');
186
222
  const head = pageHead(options.head);
187
223
  const cache = pageCache(options.cache, live);
@@ -192,8 +228,8 @@ function page(routePath, options = {}) {
192
228
  PAGE_METADATA.set(PageClass, Object.freeze({
193
229
  path: routePath,
194
230
  template,
195
- scope,
196
- ...(options.socket && { socket: options.socket }),
231
+ scope,
232
+ ...(options.socket && { socket: options.socket }),
197
233
  ...(live === false && { live: false }),
198
234
  ...(head && { head }),
199
235
  ...(cache && { cache }),
@@ -250,6 +286,71 @@ function state(options = {}) {
250
286
  };
251
287
  }
252
288
 
289
+ function resource(liveResource, select) {
290
+ const { LiveResource } = require('./LiveResource');
291
+ if (!(liveResource instanceof LiveResource)) throw new TypeError('resource() requires a value created by liveResource().');
292
+ if (typeof select !== 'function') throw new TypeError('resource() requires a key selector.');
293
+ const config = Object.freeze({ resource: liveResource, select });
294
+ return (target, property) => {
295
+ if (property?.kind === 'field') {
296
+ if (property.static || property.private || typeof property.name !== 'string' || !property.name) {
297
+ throw new TypeError('resource() requires a public instance field with a string name.');
298
+ }
299
+ property.addInitializer(function registerStandardResource() {
300
+ registerState(this.constructor, property.name, Object.freeze({ writable: false }));
301
+ registerResource(this.constructor, property.name, config);
302
+ });
303
+ return initialValue => initialValue;
304
+ }
305
+ const PageClass = assertDecoratorTarget(target, 'resource()');
306
+ if (typeof property !== 'string' || !property) throw new TypeError('Resource property must be a non-empty string.');
307
+ registerState(PageClass, property, Object.freeze({ writable: false }));
308
+ registerResource(PageClass, property, config);
309
+ };
310
+ }
311
+
312
+ function inject(provider) {
313
+ if (typeof provider !== 'string' || !provider || provider.length > 128 || ['__proto__', 'prototype', 'constructor'].includes(provider)) {
314
+ throw new TypeError('inject() requires a safe non-empty provider name of at most 128 characters.');
315
+ }
316
+ return (target, property) => {
317
+ if (property?.kind === 'field') {
318
+ if (property.static || property.private || typeof property.name !== 'string' || !property.name) {
319
+ throw new TypeError('inject() requires a public instance field with a string name.');
320
+ }
321
+ property.addInitializer(function registerStandardInject() { registerInject(this.constructor, property.name, provider); });
322
+ return initialValue => initialValue;
323
+ }
324
+ const PageClass = assertDecoratorTarget(target, 'inject()');
325
+ if (typeof property !== 'string' || !property) throw new TypeError('Injected property must be a non-empty string.');
326
+ registerInject(PageClass, property, provider);
327
+ };
328
+ }
329
+
330
+ function upload(options = {}) {
331
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('upload() options must be an object.');
332
+ const { maxBytes = 10 * 1024 * 1024, accept = [] } = options;
333
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 1024 * 1024 * 1024) throw new RangeError('upload() maxBytes must be an integer between 1 and 1 GiB.');
334
+ const types = Array.isArray(accept) ? accept : [accept];
335
+ if (types.some(type => typeof type !== 'string' || !/^[a-z]+\/(?:[a-z0-9.+-]+|\*)$/.test(type))) throw new TypeError('upload() accept must contain MIME types or type wildcards.');
336
+ const config = Object.freeze({ maxBytes, accept: Object.freeze([...new Set(types)]) });
337
+ return (target, method, descriptor) => {
338
+ if (method?.kind === 'method') {
339
+ if (method.static || method.private || typeof method.name !== 'string' || !method.name || typeof target !== 'function') throw new TypeError('upload() requires a public instance method with a string name.');
340
+ const entry = Object.freeze({ implementation: target, definition: new ActionDefinition() });
341
+ method.addInitializer(function registerStandardUpload() {
342
+ if (this[method.name] === target) { registerStandardAction(this.constructor, method.name, entry); registerUpload(this.constructor, method.name, config); }
343
+ });
344
+ return target;
345
+ }
346
+ const PageClass = assertDecoratorTarget(target, 'upload()');
347
+ if (typeof method !== 'string' || !method || typeof descriptor?.value !== 'function') throw new TypeError('upload() must decorate a method.');
348
+ registerAction(PageClass, method, Object.freeze({ implementation: descriptor.value, definition: new ActionDefinition() }));
349
+ registerUpload(PageClass, method, config);
350
+ return descriptor;
351
+ };
352
+ }
353
+
253
354
  function action(options) {
254
355
  const definition = new ActionDefinition(options);
255
356
  return (target, method, descriptor) => {
@@ -321,6 +422,10 @@ function getStateConfig(PageClass, property) {
321
422
  return resolvedState(PageClass).get(property);
322
423
  }
323
424
 
425
+ function getResourceMetadata(PageClass) { return new Map(resolved(RESOURCE_METADATA, RESOLVED_RESOURCE, PageClass)); }
426
+ function getInjectMetadata(PageClass) { return new Map(resolved(INJECT_METADATA, RESOLVED_INJECT, PageClass)); }
427
+ function getUploadMetadata(PageClass) { return new Map(resolved(UPLOAD_METADATA, RESOLVED_UPLOAD, PageClass)); }
428
+
324
429
  function forEachState(PageClass, callback) {
325
430
  resolvedState(PageClass).forEach(callback);
326
431
  }
@@ -349,6 +454,9 @@ module.exports = {
349
454
  getActionDefinition,
350
455
  getActionMetadata,
351
456
  getPageMetadata,
457
+ getInjectMetadata,
458
+ getUploadMetadata,
459
+ getResourceMetadata,
352
460
  getPageStylesheetRoots,
353
461
  getPageTemplateRoot,
354
462
  getStateConfig,
@@ -356,10 +464,13 @@ module.exports = {
356
464
  getViewImplementation,
357
465
  getViewMetadata,
358
466
  isComponentClass,
467
+ inject,
359
468
  page,
360
469
  pageCache,
361
470
  pageHead,
362
471
  setPageStylesheetRoots,
363
472
  state,
473
+ resource,
474
+ upload,
364
475
  view,
365
476
  };
@@ -20,12 +20,12 @@ class BaseHandler {
20
20
  */
21
21
  async handleMessage(socket, message) {
22
22
  const validationResult = await this.validateMessage(message, socket);
23
- if (validationResult === false) {
24
- throw new Error('Invalid message');
25
- }
26
- const guard = require('./HandlerGuard').guards.get(socket);
27
- if (guard) await guard();
28
- return this.onMessage(socket, message);
23
+ if (validationResult === false) {
24
+ throw new Error('Invalid message');
25
+ }
26
+ const guard = require('./HandlerGuard').guards.get(socket);
27
+ if (guard) await guard();
28
+ return this.onMessage(socket, message);
29
29
  }
30
30
 
31
31
  validateMessage() {