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
package/src/cli/run.js ADDED
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const ProjectInitializer = require('./ProjectInitializer');
5
+ const { ProjectDoctor } = require('./ProjectDoctor');
6
+ const { ProjectAddition } = require('./ProjectAddition');
7
+ const formatCommand = require('./formatCommand');
8
+ const { parseArguments, USAGE } = require('./arguments');
9
+
10
+ async function run(args, cwd, version) {
11
+ try {
12
+ const options = parseArguments(args);
13
+ if (options.command === '--version') return { exitCode: 0, stdout: `${version}\n`, stderr: '' };
14
+ if (options.command === '--help' || options.command === '-h') return { exitCode: 0, stdout: USAGE, stderr: '' };
15
+ const root = path.resolve(cwd, options.target);
16
+ if (options.command === 'doctor') {
17
+ const report = await new ProjectDoctor(version).inspect(root, options.port);
18
+ const output = options.json ? JSON.stringify(report) : [
19
+ `Redweb doctor: ${report.ok ? 'passed selected checks' : 'issues found'}`,
20
+ ...report.issues.map(value => `${value.severity} ${value.code}${value.file ? ` (${value.file}${value.line ? `:${value.line}:${value.column}` : ''})` : ''}: ${value.message}\n ${value.suggestion}`),
21
+ ].join('\n');
22
+ return { exitCode: report.ok ? 0 : 1, stdout: `${output}\n`, stderr: '' };
23
+ }
24
+ if (options.command === 'add') {
25
+ const result = new ProjectAddition().add(root, options);
26
+ const report = { schemaVersion: 1, operation: 'add', dryRun: options.dryRun, ...result };
27
+ const output = options.json ? JSON.stringify(report) : [
28
+ `${options.dryRun ? 'Planned addition' : 'Files created'} in ${result.root}`,
29
+ `Files: ${result.planned.join(', ')}`,
30
+ `Registration pending: ${result.registration.instruction}`,
31
+ `Import (adjust relative path from your entry point): ${result.registration.importFromProjectRoot}`,
32
+ `Build: ${formatCommand(result.verification.build)}`,
33
+ `Test: ${formatCommand(result.verification.test)}`,
34
+ result.verification.note,
35
+ ].join('\n');
36
+ return { exitCode: 0, stdout: `${output}\n`, stderr: '' };
37
+ }
38
+ const result = new ProjectInitializer(version).initialize(root, options);
39
+ const report = { schemaVersion: 1, operation: 'init', dryRun: options.dryRun, ...result };
40
+ const output = options.json ? JSON.stringify(report) : [
41
+ `${options.dryRun ? 'Planned initialization' : 'Initialization complete'} in ${result.root}`,
42
+ `Created: ${result.created.join(', ')}`,
43
+ `Kept existing: ${result.skipped.join(', ')}`,
44
+ `Planned: ${result.planned.join(', ')}`,
45
+ 'Existing files were not validated or changed. Run redweb doctor to check configuration.',
46
+ options.existing ? 'No application or package files were generated.' : 'Next (published releases): npm install && npm run dev. Unreleased builds: install the matching Redweb tarball first (see README.md), then npm run dev.',
47
+ ].join('\n');
48
+ return { exitCode: 0, stdout: `${output}\n`, stderr: '' };
49
+ } catch (error) {
50
+ const output = args.includes('--json')
51
+ ? JSON.stringify({ schemaVersion: 1, ok: false, error: { code: 'CLI_FAILED', message: error.message } })
52
+ : `${error.message}\nRun redweb --help for usage.`;
53
+ return { exitCode: 1, stdout: '', stderr: `${output}\n` };
54
+ }
55
+ }
56
+
57
+ module.exports = { run };
@@ -1,87 +1,86 @@
1
- 'use strict';
2
-
3
- const TYPESCRIPT_CONFIG = `${JSON.stringify({
4
- extends: 'redweb/tsconfig.json',
5
- compilerOptions: { rootDir: 'src', outDir: 'dist' },
6
- include: ['src/**/*.ts', 'src/**/*.tsx'],
7
- }, null, 2)}\n`;
8
-
9
- const APP_SOURCE = `import path from 'node:path';
10
- import { page, start } from 'redweb';
11
-
12
- @page('/', { css: 'app.css', live: false })
13
- class HomePage {
14
- render() {
15
- return (
16
- <main class="home">
17
- <span class="eyebrow">Redweb</span>
18
- <h1>Your server-rendered app is ready.</h1>
19
- <p>Edit <code>src/app.tsx</code>, then build again.</p>
20
- </main>
21
- );
22
- }
23
- }
24
-
25
- start(HomePage, {
26
- port: Number(process.env.PORT ?? 8181),
27
- templateRoot: path.resolve('src'),
28
- });
29
- `;
30
-
31
- const APP_STYLES = `:root {
32
- color-scheme: dark;
33
- font-family: Inter, ui-sans-serif, system-ui, sans-serif;
34
- background: #08090d;
35
- color: #fff;
36
- }
37
-
38
- body { margin: 0; }
39
-
40
- .home {
41
- width: min(42rem, calc(100% - 2rem));
42
- margin: 18vh auto 0;
43
- }
44
-
45
- .eyebrow {
46
- color: #ff5064;
47
- font-size: 0.75rem;
48
- font-weight: 800;
49
- letter-spacing: 0.18em;
50
- text-transform: uppercase;
51
- }
52
-
53
- h1 {
54
- margin: 0.75rem 0;
55
- font-size: clamp(2.5rem, 8vw, 5rem);
56
- line-height: 0.98;
57
- }
58
-
59
- p { color: rgb(255 255 255 / 65%); }
60
- code { color: #ff8795; }
61
- `;
62
-
63
- function projectManifest(version) {
64
- return `${JSON.stringify({
65
- name: 'redweb-app',
66
- private: true,
67
- version: '0.0.0',
68
- scripts: {
69
- build: 'tsc',
70
- start: 'node dist/app.js',
71
- dev: 'npm run build && npm start',
72
- },
73
- dependencies: { redweb: `^${version}` },
74
- devDependencies: { typescript: '^5.9.3' },
75
- }, null, 2)}\n`;
76
- }
77
-
78
- function projectFiles(version) {
79
- return Object.freeze([
80
- Object.freeze({ path: 'package.json', content: projectManifest(version) }),
81
- Object.freeze({ path: 'tsconfig.json', content: TYPESCRIPT_CONFIG }),
82
- Object.freeze({ path: 'src/app.tsx', content: APP_SOURCE }),
83
- Object.freeze({ path: 'src/app.css', content: APP_STYLES }),
84
- ]);
85
- }
86
-
87
- module.exports = { projectFiles };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const json = value => `${JSON.stringify(value, null, 2)}\n`;
7
+ const TEMPLATES = Object.freeze(['realtime', 'chat', 'site', 'socket', 'dashboard', 'http-ws']);
8
+
9
+ function projectFiles(version, template = 'realtime', root = path.resolve(__dirname, '../..')) {
10
+ if (!TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
11
+ const { devDependencies, dependencies } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
12
+ const read = relative => fs.readFileSync(path.join(root, 'recipes', relative), 'utf8');
13
+ const manifest = {
14
+ name: 'redweb-app', private: true, version: '0.0.0',
15
+ scripts: {
16
+ build: 'tsc && node scripts/copy-assets.cjs',
17
+ start: 'node dist/app.js',
18
+ dev: 'nodemon',
19
+ test: 'npm run build && node --test test/app.test.cjs test/run-app.test.cjs',
20
+ 'test:coverage': 'npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/run-app.test.cjs',
21
+ },
22
+ dependencies: {
23
+ redweb: `^${version}`,
24
+ ...(['chat', 'socket', 'dashboard'].includes(template) ? { zod: devDependencies.zod } : {}),
25
+ ...(template === 'dashboard' ? { express: dependencies.express } : {}),
26
+ },
27
+ devDependencies: {
28
+ typescript: devDependencies.typescript, nodemon: devDependencies.nodemon, ws: dependencies.ws, c8: devDependencies.c8,
29
+ ...(template === 'dashboard' ? {
30
+ '@types/node': devDependencies['redweb-dashboard-types'].replace('npm:@types/node@', ''),
31
+ '@types/express': dependencies['@types/express'],
32
+ } : {}),
33
+ },
34
+ nodemonConfig: {
35
+ env: { REDWEB_DEV_REFRESH: '1' },
36
+ watch: ['src', 'tsconfig.json'],
37
+ ext: 'ts,tsx,css,html,json',
38
+ exec: 'npm run build && npm start || exit 1',
39
+ delay: 200,
40
+ },
41
+ };
42
+ if (template === 'dashboard') {
43
+ manifest.engines = { node: '>=22.13.0' };
44
+ manifest.scripts['add-user'] = 'npm run build && node dist/admin.js';
45
+ manifest.scripts['test:coverage'] += ' test/rate-window.test.cjs';
46
+ }
47
+ const files = [
48
+ { path: 'package.json', content: json(manifest) },
49
+ { path: 'tsconfig.json', content: json({
50
+ extends: 'redweb/tsconfig.json',
51
+ compilerOptions: { rootDir: 'src', outDir: 'dist', sourceMap: true },
52
+ include: ['src/**/*.ts', 'src/**/*.tsx'],
53
+ }) },
54
+ { path: 'src/app.tsx', content: read(`${template}/app.tsx`) },
55
+ { path: 'src/run-app.ts', content: read('shared/run-app.ts') },
56
+ { path: 'src/app.css', content: read(`${template === 'dashboard' ? template : 'shared'}/app.css`) },
57
+ { path: 'scripts/copy-assets.cjs', content: read('shared/copy-assets.cjs') },
58
+ { path: 'test/network.cjs', content: read('shared/network.cjs') },
59
+ { path: 'test/app.test.cjs', content: read(`${template}/app.test.cjs`) },
60
+ { path: 'test/run-app.test.cjs', content: read('shared/run-app.test.cjs') },
61
+ { path: 'README.md', content: `${read('shared/README.md')}\n${read(`${template}/README.md`)}` },
62
+ { path: '.gitignore', content: 'node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n' },
63
+ ];
64
+ if (template === 'chat') {
65
+ // The canonical component example is also the starter: one implementation to maintain.
66
+ const examples = path.join(root, 'examples/live-html');
67
+ for (const name of ['chatroom.tsx', 'chatroom.css']) {
68
+ files.push({ path: `src/${name}`, content: fs.readFileSync(path.join(examples, name), 'utf8') });
69
+ }
70
+ }
71
+ if (template === 'socket') {
72
+ for (const name of ['contract.ts', 'handlers.ts']) {
73
+ files.push({ path: `src/${name}`, content: read(`socket/${name}`) });
74
+ }
75
+ }
76
+ if (template === 'dashboard') {
77
+ files.push({ path: '.npmrc', content: 'engine-strict=true\n' });
78
+ files.push({ path: 'test/rate-window.test.cjs', content: read('dashboard/rate-window.test.cjs') });
79
+ for (const name of ['store.ts', 'auth.ts', 'cards.tsx', 'admin.ts']) {
80
+ files.push({ path: `src/${name}`, content: read(`dashboard/${name}`) });
81
+ }
82
+ }
83
+ return Object.freeze(files.map(Object.freeze));
84
+ }
85
+
86
+ module.exports = { projectFiles, TEMPLATES };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ /** Retain only portable request data, never an Express response/socket graph. */
4
+ function requestSnapshot(request) {
5
+ let bytes = 0;
6
+ const copy = (value, depth = 0) => {
7
+ if (depth > 16) throw new TypeError('Page request data exceeds the nesting limit.');
8
+ bytes += 8;
9
+ if (bytes > 65536) throw new TypeError('Page request data exceeds 64 KiB.');
10
+ if (value === undefined || value === null || typeof value === 'boolean') return value;
11
+ if (typeof value === 'string') {
12
+ bytes += Buffer.byteLength(value);
13
+ if (bytes > 65536) throw new TypeError('Page request data exceeds 64 KiB.');
14
+ return value;
15
+ }
16
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
17
+ if (typeof value !== 'object' || (!Array.isArray(value) && Object.getPrototypeOf(value) !== null && Object.getPrototypeOf(Object.getPrototypeOf(value)) !== null)) {
18
+ throw new TypeError('Page request data must contain only JSON-compatible values.');
19
+ }
20
+ if (Array.isArray(value)) {
21
+ if (value.length > 8192) throw new TypeError('Page request data exceeds 64 KiB.');
22
+ return Object.freeze(value.map(item => copy(item, depth + 1)));
23
+ }
24
+ return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [copy(key, depth + 1), copy(item, depth + 1)])));
25
+ };
26
+ const url = new URL(request.url || '/', 'http://redweb.invalid');
27
+ const query = Object.create(null);
28
+ for (const [key, value] of url.searchParams) {
29
+ query[key] = Object.hasOwn(query, key) ? [].concat(query[key], value) : value;
30
+ }
31
+ const data = copy({
32
+ path: request.path || url.pathname, url: request.url || '/', method: request.method || 'GET',
33
+ headers: request.headers || {}, params: request.params || {}, query: request.query || query, body: request.body,
34
+ });
35
+ return Object.freeze({ ...data, get(name) {
36
+ const value = Object.hasOwn(data.headers, name.toLowerCase()) ? data.headers[name.toLowerCase()] : undefined;
37
+ return Array.isArray(value) ? value.join(', ') : value;
38
+ } });
39
+ }
40
+
41
+ module.exports = requestSnapshot;
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ /** Read a data descriptor, never an application accessor. Not a Proxy sandbox. */
4
+ function dataProperty(object, name) {
5
+ for (let current = object; current && (typeof current === 'object' || typeof current === 'function'); current = Object.getPrototypeOf(current)) {
6
+ const descriptor = Object.getOwnPropertyDescriptor(current, name);
7
+ if (descriptor) return Object.prototype.hasOwnProperty.call(descriptor, 'value') ? descriptor.value : undefined;
8
+ }
9
+ }
10
+
11
+ module.exports = dataProperty;
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const { randomUUID } = require('crypto');
4
+ const { PageManager } = require('../htmx/PageManager');
5
+ const TemplateRenderer = require('../htmx/TemplateRenderer');
6
+ const loopbackRequest = require('./loopbackRequest');
7
+ const refreshBrowser = require('./refreshBrowser');
8
+
9
+ const PATHS = Object.freeze({ developmentRevision: '/__redweb/development', developmentRuntime: '/__redweb/development.js', developmentStyles: '/__redweb/development.css' });
10
+
11
+ class DevelopmentPageManager extends PageManager {
12
+ constructor(options) {
13
+ super(options, PATHS);
14
+ this.revision = randomUUID();
15
+ }
16
+
17
+ mount(app) {
18
+ const serve = (type, content) => (request, response) => {
19
+ response.set('Cache-Control', 'private, no-store').set('X-Content-Type-Options', 'nosniff');
20
+ response.removeHeader('Access-Control-Allow-Origin');
21
+ if (!loopbackRequest(request)) { response.status(404).end(); return; }
22
+ response.type(type).end(content);
23
+ };
24
+ app.get(PATHS.developmentRevision, serve('application/json', JSON.stringify({ revision: this.revision })));
25
+ app.get(PATHS.developmentRuntime, serve('text/javascript', refreshBrowser()));
26
+ app.get(PATHS.developmentStyles, serve('text/css', require('./refreshStyles')));
27
+ super.mount(app);
28
+ }
29
+
30
+ createDocument(record, request) {
31
+ const document = super.createDocument(record, request);
32
+ if (!loopbackRequest(request)) return document;
33
+ // Capture only a declaration string, never the HTTP request or credentials.
34
+ const bootstrap = `<rw-dev-refresh id="__redweb_dev"></rw-dev-refresh><script type="module" src="${PATHS.developmentRuntime}?revision=${this.revision}"></script>`;
35
+ return (markup, config) => {
36
+ const html = document(markup, config);
37
+ const body = TemplateRenderer.closingTag(html, 'body');
38
+ return body < 0 ? html + bootstrap : html.slice(0, body) + bootstrap + html.slice(body);
39
+ };
40
+ }
41
+
42
+ respond(record, request, response, markup) {
43
+ if (!loopbackRequest(request)) return super.respond(record, request, response, markup);
44
+ response.set('Cache-Control', 'private, no-store').type('html').end(markup);
45
+ }
46
+ }
47
+
48
+ module.exports = DevelopmentPageManager;
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const LivePage = require('../htmx/LivePage');
4
+ const { list, text, members, freeze, mapSize } = require('./description');
5
+ const dataProperty = require('../dataProperty');
6
+ const { observedRenderer, rendererRoute } = require('./ObservedRenderer');
7
+ const developmentSettings = require('./settings');
8
+ const HISTORY_LIMIT = 256;
9
+
10
+ function createInspection(options) {
11
+ return developmentSettings(options).inspect ? new Inspection() : null;
12
+ }
13
+
14
+ class Inspection {
15
+ constructor() {
16
+ this.events = [];
17
+ this.sequence = 0;
18
+ this.identifiers = new WeakMap();
19
+ this.nextIdentifier = 0;
20
+ this.Renderer = observedRenderer(this);
21
+ }
22
+
23
+ id(object) {
24
+ let identifier = this.identifiers.get(object);
25
+ if (!identifier) {
26
+ identifier = ++this.nextIdentifier;
27
+ this.identifiers.set(object, identifier);
28
+ }
29
+ return identifier;
30
+ }
31
+
32
+ record(renderer, kind, details) {
33
+ // Inspector failures must not change application behavior or disclose errors.
34
+ try {
35
+ const event = freeze({ sequence: ++this.sequence, render: this.id(renderer), route: rendererRoute(renderer), kind, ...details });
36
+ if (this.events.length === HISTORY_LIMIT) this.events.shift();
37
+ this.events.push(event);
38
+ } catch { /* Inspection is best effort, never an application error path. */ }
39
+ }
40
+
41
+ snapshot(server) {
42
+ const manager = server.manager;
43
+ const sockets = manager ? server.sockets : server;
44
+ const budget = { remaining: 1000 };
45
+ const limited = (values, describe) => list(values, describe, budget);
46
+ const describe = read => {
47
+ try { return { available: true, ...read() }; }
48
+ catch { return { available: false }; }
49
+ };
50
+ return freeze({
51
+ schemaVersion: 1,
52
+ mode: 'development',
53
+ pages: describe(() => manager ? this.pages(manager, limited) : { registrations: limited([]), sessions: limited([]) }),
54
+ sockets: describe(() => ({ routes: limited(sockets ? dataProperty(sockets, 'routes') : [], route => {
55
+ const rooms = dataProperty(route, 'rooms'), sessions = dataProperty(route, 'sessions');
56
+ return {
57
+ path: text(dataProperty(route, 'path')), handlers: limited(dataProperty(route, 'handlers'), handler => text(dataProperty(handler, 'name'))),
58
+ registeredConnections: mapSize(dataProperty(route, 'clients')), draining: dataProperty(route, 'draining') === true,
59
+ rooms: rooms === null ? 0 : mapSize(dataProperty(rooms, 'rooms')),
60
+ sessions: sessions === null ? 0 : mapSize(dataProperty(sessions, 'sessions')),
61
+ };
62
+ }), pendingUpgrades: sockets ? mapSize(dataProperty(sockets, 'pendingUpgrades')) : 0, draining: dataProperty(sockets, 'draining') === true })),
63
+ history: { items: [...this.events], total: this.sequence, truncated: this.sequence > this.events.length,
64
+ limit: HISTORY_LIMIT },
65
+ });
66
+ }
67
+
68
+ pages(manager, limited) {
69
+ const sessions = new Set([...manager.pending.values(), ...manager.active.values()]);
70
+ const connections = { connected: 0, detaching: 0, pending: 0, retained: 0 };
71
+ const instances = new Map();
72
+ const remember = (record, instance) => {
73
+ if (!instances.has(record)) instances.set(record, new Set());
74
+ instances.get(record).add(instance);
75
+ };
76
+ for (const session of sessions) {
77
+ remember(session.record, session.page);
78
+ connections[sessionStatus(manager, session)]++;
79
+ }
80
+ for (const record of manager.records.values()) if (record.shared) remember(record, record.shared);
81
+ return {
82
+ closing: manager.closing, rendering: manager.rendering, connections,
83
+ registrations: limited(manager.records.values(), record => ({
84
+ path: text(record.metadata.path), live: record.metadata.live !== false, shared: record.metadata.scope === 'shared',
85
+ ...members(dataProperty(record, 'PageClass'), limited),
86
+ instanceMetadata: instances.has(record) ? 'observed' : 'unobserved',
87
+ instances: limited(instances.get(record) || [], instance => ({ id: this.id(instance), ...LivePage.describe(instance, limited) })),
88
+ })),
89
+ sessions: limited(sessions, session => ({
90
+ render: this.id(session.renderLifetime), instance: this.id(session.page), route: text(session.record.metadata.path),
91
+ status: sessionStatus(manager, session),
92
+ reactive: Boolean(session.renderer),
93
+ })),
94
+ };
95
+ }
96
+ }
97
+
98
+ function sessionStatus(manager, session) {
99
+ if (session.detaching) return 'detaching';
100
+ if (session.socket) return session.socket.readyState === 1 ? 'connected' : 'detaching';
101
+ return manager.pending.has(session.id) ? 'pending' : 'retained';
102
+ }
103
+
104
+ module.exports = { Inspection, createInspection };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const { performance } = require('perf_hooks');
4
+ const ReactiveRenderer = require('../htmx/ReactiveRenderer');
5
+ const { getPageMetadata } = require('../htmx/metadata');
6
+ const { list, text } = require('./description');
7
+ const dataProperty = require('../dataProperty');
8
+
9
+ // A separate implementation is selected once for inspected servers. No observer
10
+ // checks, callbacks or extra data structures enter the ordinary update path.
11
+ function observedRenderer(inspection) {
12
+ return class ObservedRenderer extends ReactiveRenderer {
13
+ invalidate(owner, name, payload) {
14
+ if (!this.disposed) inspection.record(this, 'state-invalidated', {
15
+ state: text(name), component: text(payload.component || 'root'),
16
+ affectedOwners: list([...this.nodes.values()].filter(node => node.dependencies.get(owner)?.has(name)), node => text(node.id)),
17
+ });
18
+ return super.invalidate(owner, name, payload);
19
+ }
20
+
21
+ async performFlush() {
22
+ const started = performance.now();
23
+ const generation = this.generation;
24
+ inspection.record(this, 'flush-started', { snapshot: this.snapshot === true, dirtyOwners: list(this.dirty) });
25
+ try {
26
+ await super.performFlush();
27
+ inspection.record(this, this.disposed || generation !== this.generation ? 'flush-superseded' : 'flush-completed', {
28
+ durationMs: performance.now() - started,
29
+ });
30
+ } catch (error) {
31
+ inspection.record(this, 'flush-failed', { durationMs: performance.now() - started });
32
+ throw error;
33
+ }
34
+ }
35
+ };
36
+ }
37
+
38
+ function rendererRoute(renderer) {
39
+ return text(getPageMetadata(dataProperty(dataProperty(renderer, 'page'), 'constructor'))?.path);
40
+ }
41
+
42
+ module.exports = { observedRenderer, rendererRoute };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ const { getActionMetadata, getStateMetadata } = require('../htmx/metadata');
4
+ const dataProperty = require('../dataProperty');
5
+ const LIMIT = 100;
6
+ const text = value => typeof value === 'string' ? value.slice(0, 128) : '[unavailable]';
7
+
8
+ function list(values, describe = text, budget) {
9
+ const items = [];
10
+ let total = 0;
11
+ for (const value of values) {
12
+ if (total++ < LIMIT && (!budget || budget.remaining > 0)) {
13
+ if (budget) budget.remaining--;
14
+ items.push(describe(value));
15
+ }
16
+ }
17
+ return { items, total, truncated: total > items.length };
18
+ }
19
+
20
+ function members(Class, describeList = list) {
21
+ return { className: text(dataProperty(Class, 'name')), actions: describeList(typeof Class === 'function' ? getActionMetadata(Class) : []),
22
+ states: describeList(typeof Class === 'function' ? getStateMetadata(Class).keys() : []) };
23
+ }
24
+
25
+ const mapSize = map => Object.getOwnPropertyDescriptor(Map.prototype, 'size').get.call(map);
26
+
27
+ function freeze(value) {
28
+ if (value && typeof value === 'object') {
29
+ Object.values(value).forEach(freeze);
30
+ Object.freeze(value);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ module.exports = { list, text, members, freeze, mapSize };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ const { isIP } = require('net');
4
+
5
+ function loopback(address) {
6
+ if (typeof address !== 'string') return false;
7
+ if (address === '::1') return true;
8
+ const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address;
9
+ return isIP(ipv4) === 4 && ipv4.startsWith('127.');
10
+ }
11
+
12
+ function loopbackRequest(request) {
13
+ if (!loopback(request.socket?.remoteAddress)) return false;
14
+ const host = request.headers?.host;
15
+ if (typeof host !== 'string') return false;
16
+ const match = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])(?::(\d+))?$/i.exec(host);
17
+ if (!match || (match[1].startsWith('127.') && !loopback(match[1]))) return false;
18
+ const secure = Boolean(request.socket.encrypted);
19
+ const defaultPort = secure ? 443 : 80;
20
+ const port = match[2] === undefined ? defaultPort : Number(match[2]);
21
+ if (port !== request.socket.localPort) return false;
22
+ const origin = `${secure ? 'https' : 'http'}://${match[1].toLowerCase()}${port === defaultPort ? '' : `:${port}`}`;
23
+ if (request.headers.origin !== undefined && request.headers.origin !== origin) return false;
24
+ return request.headers['sec-fetch-site'] === undefined || ['same-origin', 'none'].includes(request.headers['sec-fetch-site']);
25
+ }
26
+
27
+ module.exports = loopbackRequest;
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ function refreshBrowser() {
4
+ return `
5
+ const source = new URL(import.meta.url);
6
+ const initialRevision = source.searchParams.get('revision');
7
+ const endpoint = new URL('./development', source);
8
+ const host = document.getElementById('__redweb_dev');
9
+ const validRevision = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
10
+ if (host && validRevision(initialRevision)) {
11
+ const editable = target => target instanceof Element && Boolean(target.closest('input:not([type="hidden"]), textarea, select, [contenteditable]:not([contenteditable="false"])'));
12
+ const selectionChanged = select => {
13
+ const form = document.createElement('form');
14
+ const baseline = select.cloneNode(true);
15
+ baseline.removeAttribute('form');
16
+ form.append(baseline);
17
+ HTMLFormElement.prototype.reset.call(form);
18
+ return [...select.options].some((option, index) => option.selected !== baseline.options[index].selected);
19
+ };
20
+ // A conservative guard, not an autosave or a precise unsaved-change detector.
21
+ let edited = editable(document.activeElement) || [...document.querySelectorAll('input, textarea, select, [contenteditable]')].some(node =>
22
+ node.isContentEditable || (node.localName === 'select' ? selectionChanged(node) :
23
+ node.type === 'file' ? node.files.length > 0 : node.type === 'checkbox' || node.type === 'radio' ? node.checked !== node.defaultChecked :
24
+ node.type !== 'hidden' && node.value !== node.defaultValue));
25
+ const markEdited = event => { if (event.composedPath().some(editable)) edited = true; };
26
+ document.addEventListener('input', markEdited, true);
27
+ document.addEventListener('change', markEdited, true);
28
+ const shadow = host.attachShadow({ mode: 'open' });
29
+ let stopped = false, restartDetected = false, lifetime = 0, timer, request;
30
+ const cleanup = () => {
31
+ stopped = true;
32
+ lifetime += 1;
33
+ clearTimeout(timer);
34
+ request?.abort();
35
+ request = null;
36
+ document.removeEventListener('input', markEdited, true);
37
+ document.removeEventListener('change', markEdited, true);
38
+ };
39
+ const confirmReload = () => {
40
+ const stylesheet = document.createElement('link');
41
+ stylesheet.rel = 'stylesheet';
42
+ stylesheet.href = new URL('./development.css', source).href;
43
+ const notice = document.createElement('aside');
44
+ notice.setAttribute('role', 'status');
45
+ notice.setAttribute('aria-label', 'Redweb development refresh');
46
+ const message = document.createElement('p');
47
+ message.textContent = 'Development server restarted. Your current document is kept because edits were detected. Reloading resets these drafts and server memory may have changed.';
48
+ const button = document.createElement('button');
49
+ button.type = 'button';
50
+ button.textContent = 'Reload and discard drafts';
51
+ button.addEventListener('click', () => location.reload());
52
+ notice.append(message, button);
53
+ shadow.append(stylesheet, notice);
54
+ };
55
+ const poll = async () => {
56
+ if (stopped) return;
57
+ const generation = lifetime;
58
+ const controller = new AbortController();
59
+ request = controller;
60
+ const timeout = setTimeout(() => controller.abort(), 2000);
61
+ try {
62
+ const response = await fetch(endpoint, { cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal });
63
+ if (response.ok && response.headers.get('content-type')?.startsWith('application/json')) {
64
+ const result = await response.json();
65
+ if (!stopped && generation === lifetime && validRevision(result.revision) && result.revision !== initialRevision) {
66
+ restartDetected = true;
67
+ cleanup();
68
+ if (edited) confirmReload();
69
+ else location.reload();
70
+ }
71
+ }
72
+ } catch { /* Outage, rebuild failure or malformed response is not a new revision. */ }
73
+ finally {
74
+ clearTimeout(timeout);
75
+ if (generation === lifetime) {
76
+ request = null;
77
+ // cleanup always advances lifetime before stopping this generation.
78
+ timer = setTimeout(poll, 1000);
79
+ }
80
+ }
81
+ };
82
+ window.addEventListener('pagehide', cleanup);
83
+ window.addEventListener('pageshow', event => {
84
+ if (event.persisted && !restartDetected) {
85
+ stopped = false;
86
+ document.addEventListener('input', markEdited, true);
87
+ document.addEventListener('change', markEdited, true);
88
+ poll();
89
+ }
90
+ });
91
+ poll();
92
+ }
93
+ `;
94
+ }
95
+
96
+ module.exports = refreshBrowser;