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.
- package/CHANGELOG.md +166 -9
- package/README.md +177 -523
- package/bin/redweb.js +11 -20
- package/client.d.ts +7 -2
- package/config/tsconfig.json +14 -14
- package/contract.d.ts +45 -0
- package/contract.js +5 -0
- package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
- package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
- package/docs/AGENT_ACCESS.md +35 -0
- package/docs/AGENT_EVALUATION.md +58 -0
- package/docs/AGENT_READY_ACCEPTANCE.md +763 -0
- package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
- package/docs/BENCHMARK_VERIFICATION.md +307 -0
- package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
- package/docs/CLI.md +116 -0
- package/docs/CLIENT_DEVELOPMENT.md +152 -0
- package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
- package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
- package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
- package/docs/DEVELOPMENT.md +79 -0
- package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
- package/docs/DOCUMENTATION.md +37 -0
- package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
- package/docs/GETTING_STARTED.md +58 -0
- package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
- package/docs/LIVE_HTML.md +169 -21
- package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
- package/docs/MIGRATION.md +28 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
- package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
- package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
- package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
- package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
- package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
- package/docs/PRODUCTION_READINESS.md +11 -2
- package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
- package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
- package/docs/RECOVERY_CODE_CENSUS.md +158 -0
- package/docs/RECOVERY_COMPARISON.md +103 -0
- package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
- package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
- package/docs/RECOVERY_INVESTIGATION.md +229 -0
- package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
- package/docs/RELEASE_TRUST.md +58 -0
- package/docs/ROOM_AUTHORIZATION.md +49 -0
- package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
- package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
- package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
- package/docs/SOAK_VERIFICATION.md +154 -0
- package/docs/SOCKET_CONTRACTS.md +39 -0
- package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
- package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
- package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
- package/docs/STARTER_REPORT_RETENTION.md +73 -0
- package/docs/VERIFICATION_EVIDENCE.md +2 -0
- package/docs/generated.json +2154 -0
- package/docs/guides/chatroom.md +27 -0
- package/docs/guides/http-websocket.md +28 -0
- package/docs/guides/jsx-without-react.md +26 -0
- package/docs/guides/realtime-dashboard.md +29 -0
- package/docs/guides/typed-websockets.md +26 -0
- package/docs/reference.json +1207 -0
- package/docs/snippets/room-access.tsx +51 -0
- package/docs/topics.json +21 -0
- package/examples/live-html/chatroom.js +207 -268
- package/examples/live-html/chatroom.tsx +167 -0
- package/examples/live-html/jsx-page.js +1 -1
- package/examples/live-html/jsx-page.tsx +1 -1
- package/examples/live-html/tsconfig.json +8 -7
- package/index.d.ts +170 -45
- package/index.js +2 -0
- package/jsx-dev-runtime.js +2 -2
- package/jsx-runtime.d.ts +7 -2
- package/package.json +88 -7
- package/recipes/add/artifact.test.cjs +57 -0
- package/recipes/add/live.tsx +18 -0
- package/recipes/add/socket-route.ts +24 -0
- package/recipes/chat/README.md +22 -0
- package/recipes/chat/app.test.cjs +105 -0
- package/recipes/chat/app.tsx +9 -0
- package/recipes/dashboard/README.md +43 -0
- package/recipes/dashboard/admin.ts +21 -0
- package/recipes/dashboard/app.css +16 -0
- package/recipes/dashboard/app.test.cjs +450 -0
- package/recipes/dashboard/app.tsx +86 -0
- package/recipes/dashboard/auth.ts +80 -0
- package/recipes/dashboard/cards.tsx +102 -0
- package/recipes/dashboard/rate-window.test.cjs +17 -0
- package/recipes/dashboard/store.ts +120 -0
- package/recipes/http-ws/README.md +11 -0
- package/recipes/http-ws/app.test.cjs +92 -0
- package/recipes/http-ws/app.tsx +36 -0
- package/recipes/realtime/README.md +8 -0
- package/recipes/realtime/app.test.cjs +15 -0
- package/recipes/realtime/app.tsx +28 -0
- package/recipes/shared/README.md +40 -0
- package/recipes/shared/app.css +8 -0
- package/recipes/shared/copy-assets.cjs +8 -0
- package/recipes/shared/network.cjs +59 -0
- package/recipes/shared/run-app.test.cjs +158 -0
- package/recipes/shared/run-app.ts +50 -0
- package/recipes/site/README.md +4 -0
- package/recipes/site/app.test.cjs +19 -0
- package/recipes/site/app.tsx +25 -0
- package/recipes/socket/README.md +39 -0
- package/recipes/socket/app.test.cjs +85 -0
- package/recipes/socket/app.tsx +30 -0
- package/recipes/socket/contract.ts +12 -0
- package/recipes/socket/handlers.ts +40 -0
- package/src/OwnedServerLifecycle.js +66 -0
- package/src/access/AccessPolicy.js +37 -0
- package/src/access/AuthenticationFailure.js +13 -0
- package/src/access/RequestFailure.js +33 -0
- package/src/access/failure-codes.json +25 -0
- package/src/async/BoundedOperation.js +62 -0
- package/src/cli/ActionReferences.js +193 -0
- package/src/cli/AdditionLayout.js +140 -0
- package/src/cli/FilePlan.js +94 -0
- package/src/cli/ProjectAddition.js +60 -0
- package/src/cli/ProjectConfig.js +26 -0
- package/src/cli/ProjectDoctor.js +112 -0
- package/src/cli/ProjectInitializer.js +20 -30
- package/src/cli/SourceInspector.js +207 -0
- package/src/cli/StaticSource.js +192 -0
- package/src/cli/arguments.js +62 -0
- package/src/cli/formatCommand.js +10 -0
- package/src/cli/run.js +57 -0
- package/src/cli/templates.js +86 -87
- package/src/context/RequestSnapshot.js +41 -0
- package/src/dataProperty.js +11 -0
- package/src/development/DevelopmentPageManager.js +48 -0
- package/src/development/Inspection.js +104 -0
- package/src/development/ObservedRenderer.js +42 -0
- package/src/development/description.js +35 -0
- package/src/development/loopbackRequest.js +27 -0
- package/src/development/refreshBrowser.js +96 -0
- package/src/development/refreshStyles.js +9 -0
- package/src/development/settings.js +17 -0
- package/src/docs/Documentation.js +182 -0
- package/src/htmx/ActionDefinition.js +44 -0
- package/src/htmx/Jsx.js +24 -8
- package/src/htmx/LiveHtmlServer.js +41 -19
- package/src/htmx/LivePage.js +63 -13
- package/src/htmx/PageIdentity.js +32 -0
- package/src/htmx/PageLifetime.js +37 -0
- package/src/htmx/PageManager.js +203 -74
- package/src/htmx/ReactiveRenderer.js +241 -0
- package/src/htmx/StaticExporter.js +1 -1
- package/src/htmx/TemplateRenderer.js +13 -7
- package/src/htmx/browserRuntime.js +2 -93
- package/src/htmx/metadata.js +19 -7
- package/src/validation/ActionInputError.js +12 -0
- package/src/validation/SchemaValidator.js +38 -0
- package/src/ws/AdmissionPolicy.js +24 -23
- package/src/ws/BaseSocketServer.js +53 -38
- package/src/ws/ContractValidationError.js +12 -0
- package/src/ws/HeartbeatMonitor.js +19 -7
- package/src/ws/ProtocolPolicy.js +1 -1
- package/src/ws/RoomAccess.js +82 -0
- package/src/ws/RoomRegistry.js +56 -6
- package/src/ws/RouteRuntime.js +56 -10
- package/src/ws/SocketContract.js +112 -0
- package/src/ws/SocketRoute.js +18 -0
- package/src/ws/protocol-schema.json +6 -1
- package/examples/live-html/chatroom.ts +0 -217
package/src/htmx/PageManager.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
const { createHash, randomUUID } = require('crypto');
|
|
2
|
+
const { RequestFailure } = require('../access/RequestFailure');
|
|
2
3
|
const path = require('path');
|
|
4
|
+
const { setMaxListeners } = require('events');
|
|
3
5
|
const { BaseHandler } = require('../ws/BaseHandler');
|
|
4
6
|
const { SocketRoute } = require('../ws');
|
|
5
7
|
const HtmlRenderer = require('./HtmlRenderer');
|
|
6
8
|
const PageAssetLoader = require('./PageAssetLoader');
|
|
7
9
|
const LivePage = require('./LivePage');
|
|
10
|
+
const ReactiveRenderer = require('./ReactiveRenderer');
|
|
8
11
|
const browserRuntime = require('./browserRuntime');
|
|
9
12
|
const { isHtml, renderValue, trustedHtml } = require('./Html');
|
|
10
13
|
const { getPageMetadata, getPageStylesheetRoots, getPageTemplateRoot } = require('./metadata');
|
|
11
14
|
const synchronous = require('./synchronous');
|
|
15
|
+
const { AccessDenied } = require('../access/AccessPolicy');
|
|
16
|
+
const { PageIdentity, AuthenticationFailure, isPrincipal } = require('./PageIdentity');
|
|
17
|
+
const PageLifetime = require('./PageLifetime');
|
|
18
|
+
const requestSnapshot = require('../context/RequestSnapshot');
|
|
12
19
|
|
|
13
20
|
const PROTOCOL_VERSION = '1';
|
|
14
21
|
const DEFAULT_HEARTBEAT = Object.freeze({ intervalMs: 15_000, timeoutMs: 10_000 });
|
|
@@ -62,7 +69,7 @@ function matchesIfNoneMatch(header, etag) {
|
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
class PageManager {
|
|
65
|
-
constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, heartbeat = DEFAULT_HEARTBEAT, authenticate, origins, logger = console }) {
|
|
72
|
+
constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, heartbeat = DEFAULT_HEARTBEAT, authenticate, authenticationTimeoutMs, origins, logger = console }, reservedPaths = {}) {
|
|
66
73
|
if (!Array.isArray(pages) || pages.length === 0) throw new TypeError('`pages` must be a non-empty array.');
|
|
67
74
|
if (templateRoot !== undefined && (typeof templateRoot !== 'string' || !templateRoot)) throw new TypeError('`templateRoot` must be a non-empty string.');
|
|
68
75
|
if (!Number.isInteger(sessionTtlMs) || sessionTtlMs < 0) throw new TypeError('`sessionTtlMs` must be a non-negative integer.');
|
|
@@ -72,12 +79,11 @@ class PageManager {
|
|
|
72
79
|
}
|
|
73
80
|
if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
|
|
74
81
|
if (!paths || typeof paths !== 'object' || Array.isArray(paths)) throw new TypeError('`paths` must be an object.');
|
|
75
|
-
if (authenticate !== undefined && typeof authenticate !== 'function') throw new TypeError('`authenticate` must be a function.');
|
|
76
82
|
if (origins !== undefined && typeof origins !== 'function' &&
|
|
77
83
|
(!Array.isArray(origins) || origins.some(origin => typeof origin !== 'string' || !origin))) {
|
|
78
84
|
throw new TypeError('`origins` must be a function or an array of non-empty origins.');
|
|
79
85
|
}
|
|
80
|
-
this.paths = { ...DEFAULT_PATHS, ...paths };
|
|
86
|
+
this.paths = { ...DEFAULT_PATHS, ...paths, ...reservedPaths };
|
|
81
87
|
Object.entries(this.paths).forEach(([name, value]) => {
|
|
82
88
|
internalPath(value, name);
|
|
83
89
|
});
|
|
@@ -99,6 +105,8 @@ class PageManager {
|
|
|
99
105
|
this.heartbeat = heartbeat;
|
|
100
106
|
this.logger = logger || { log() {}, warn() {}, error() {} };
|
|
101
107
|
this.authenticateRequest = authenticate;
|
|
108
|
+
this.identity = new PageIdentity(authenticate, authenticationTimeoutMs);
|
|
109
|
+
this.lifetimes = new Set();
|
|
102
110
|
this.origins = origins;
|
|
103
111
|
this.pending = new Map();
|
|
104
112
|
this.active = new Map();
|
|
@@ -112,6 +120,7 @@ class PageManager {
|
|
|
112
120
|
this.renderWaiters = [];
|
|
113
121
|
this.renderPages = new Set();
|
|
114
122
|
this.renderAbortController = new AbortController();
|
|
123
|
+
setMaxListeners(maxSessions + maxConcurrentRenders + 1, this.renderAbortController.signal);
|
|
115
124
|
this.closing = false;
|
|
116
125
|
pages.forEach(PageClass => this.register(PageClass));
|
|
117
126
|
this.hasLivePages = [...this.records.values()].some(record => record.metadata.live !== false);
|
|
@@ -163,92 +172,130 @@ class PageManager {
|
|
|
163
172
|
|
|
164
173
|
mount(app) {
|
|
165
174
|
if (this.hasLivePages) {
|
|
166
|
-
const clientFile = path.join(path.dirname(require.resolve('redweb-client')), '
|
|
175
|
+
const clientFile = path.join(path.dirname(require.resolve('redweb-client/live-html')), 'live-html.js');
|
|
167
176
|
app.get(this.paths.client, (_request, response) => response.sendFile(clientFile));
|
|
168
177
|
app.get(this.paths.runtime, (_request, response) => response.type('text/javascript').send(browserRuntime(this.paths.client)));
|
|
169
178
|
}
|
|
170
179
|
this.stylesheets.forEach((content, url) => app.get(url, (_request, response) => {
|
|
171
180
|
response.set('Cache-Control', 'public, max-age=31536000, immutable').type('text/css').send(content);
|
|
172
181
|
}));
|
|
173
|
-
this.records.forEach(record => app.get(record.metadata.path, (request, response
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
response.set('Cache-Control',
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
response.type('html').send(markup);
|
|
190
|
-
}, next);
|
|
182
|
+
this.records.forEach(record => app.get(record.metadata.path, (request, response) => {
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const closed = () => controller.abort();
|
|
185
|
+
const finished = () => { response.off('close', closed); response.off('finish', finished); };
|
|
186
|
+
response.once('close', closed);
|
|
187
|
+
response.once('finish', finished);
|
|
188
|
+
this.render(record, request, controller.signal, markup => this.respond(record, request, response, markup)).catch(error => {
|
|
189
|
+
if (response.destroyed || response.writableEnded) return;
|
|
190
|
+
if (response.headersSent) { response.destroy(); return; }
|
|
191
|
+
if (this.closing) response.set('Connection', 'close');
|
|
192
|
+
const failure = RequestFailure.from(error, 'PAGE_FAILED');
|
|
193
|
+
response.set('Cache-Control', 'private, no-store').status(failure.status).json({ error: {
|
|
194
|
+
code: failure.code, message: failure.message,
|
|
195
|
+
} });
|
|
196
|
+
}).finally(finished);
|
|
191
197
|
}));
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
|
|
200
|
+
respond(record, request, response, markup) {
|
|
201
|
+
if (record.metadata.live !== false || this.authenticateRequest || record.metadata.policy) {
|
|
202
|
+
// end() deliberately bypasses Express's automatic conditional-GET/ETag handling.
|
|
203
|
+
response.set('Cache-Control', 'private, no-store').type('html').end(markup);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const etag = `"${createHash('sha256').update(markup).digest('base64url')}"`;
|
|
207
|
+
response.set('Cache-Control', cacheControl(record.metadata.cache)).set('ETag', etag);
|
|
208
|
+
if (matchesIfNoneMatch(request.headers['if-none-match'], etag)) {
|
|
209
|
+
response.status(304).end();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
response.type('html').send(markup);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
createDocument(record) {
|
|
216
|
+
return (markup, config) => HtmlRenderer.document(markup, config, record.stylesheets, record.metadata.head);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
createLifetime(principal) {
|
|
220
|
+
const lifetime = new PageLifetime(this.renderAbortController.signal);
|
|
221
|
+
lifetime.principal = principal;
|
|
222
|
+
this.lifetimes.add(lifetime);
|
|
223
|
+
return lifetime;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async render(record, request, signal, onReady) {
|
|
195
227
|
const live = record.metadata.live !== false;
|
|
196
228
|
const sessionsFull = live && this.pending.size + this.active.size + this.liveRendering >= this.maxSessions;
|
|
197
229
|
if (this.closing || this.rendering >= this.maxConcurrentRenders || sessionsFull) {
|
|
198
|
-
|
|
199
|
-
error.status = 503;
|
|
200
|
-
throw error;
|
|
230
|
+
throw new RequestFailure('PAGE_CAPACITY');
|
|
201
231
|
}
|
|
202
232
|
this.rendering += 1;
|
|
203
233
|
if (live) this.liveRendering += 1;
|
|
204
234
|
const ownsPage = record.metadata.scope === 'connection';
|
|
205
235
|
let page;
|
|
236
|
+
let renderer;
|
|
237
|
+
let session;
|
|
238
|
+
const lifetime = this.createLifetime();
|
|
239
|
+
const deliver = markup => { lifetime.check(); onReady?.(markup); return markup; };
|
|
240
|
+
signal?.addEventListener('abort', lifetime.abort, { once: true });
|
|
241
|
+
if (signal?.aborted) lifetime.abort();
|
|
206
242
|
try {
|
|
243
|
+
const snapshot = requestSnapshot(request);
|
|
244
|
+
const principal = await this.identity.resolve(request, lifetime.signal);
|
|
245
|
+
lifetime.principal = principal;
|
|
246
|
+
lifetime.check();
|
|
247
|
+
const context = Object.freeze({ request: snapshot, params: snapshot.params, query: snapshot.query, body: snapshot.body, principal, signal: lifetime.signal });
|
|
248
|
+
await record.metadata.policy?.check(context);
|
|
249
|
+
lifetime.check();
|
|
207
250
|
page = ownsPage ? this.instantiate(record) : record.shared;
|
|
251
|
+
lifetime.page = ownsPage ? page : null;
|
|
208
252
|
this.renderPages.add(page);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
throw error;
|
|
214
|
-
}
|
|
215
|
-
const context = Object.freeze({
|
|
216
|
-
request,
|
|
217
|
-
params: request.params,
|
|
218
|
-
query: request.query,
|
|
219
|
-
body: request.body,
|
|
220
|
-
principal,
|
|
221
|
-
signal: this.renderAbortController.signal,
|
|
222
|
-
});
|
|
223
|
-
await page.loading?.(context);
|
|
224
|
-
await page._loadComponents(context);
|
|
225
|
-
if (this.closing) throw new Error('Live HTML server is shutting down.');
|
|
226
|
-
const markup = await LivePage.withRenderContext(context, async () => {
|
|
253
|
+
if (live) renderer = new this.Renderer(page, lifetime.signal);
|
|
254
|
+
await lifetime.wait(() => page.loading?.(context));
|
|
255
|
+
await lifetime.wait(() => page._loadComponents(context));
|
|
256
|
+
const render = async () => {
|
|
227
257
|
const source = record.template ?? await page.render?.(context);
|
|
228
|
-
|
|
258
|
+
lifetime.check();
|
|
229
259
|
if (source === undefined) throw new Error(`${record.PageClass.name} must provide a template or render().`);
|
|
230
260
|
const content = isHtml(source) ? renderValue(source) : HtmlRenderer.render(source.toString(), page, { live });
|
|
231
261
|
if (!record.metadata.layout) return content;
|
|
232
262
|
const result = synchronous(record.metadata.layout(trustedHtml(content), context), 'Page layouts must render synchronously.');
|
|
233
263
|
if (!isHtml(result)) throw new TypeError('Page layouts must return html.');
|
|
234
264
|
return renderValue(result);
|
|
235
|
-
}
|
|
265
|
+
};
|
|
266
|
+
const withContext = callback => LivePage.withRenderContext(context, callback);
|
|
267
|
+
const markup = await lifetime.wait(() => renderer ? renderer.initialize(render, withContext) : withContext(render));
|
|
268
|
+
const document = this.createDocument(record, request);
|
|
236
269
|
if (record.metadata.live === false) {
|
|
237
|
-
const
|
|
238
|
-
if (ownsPage) await page.dispose();
|
|
239
|
-
return
|
|
270
|
+
const result = document(markup, null);
|
|
271
|
+
if (ownsPage) await lifetime.wait(() => page.dispose());
|
|
272
|
+
return deliver(result);
|
|
240
273
|
}
|
|
241
|
-
|
|
242
|
-
|
|
274
|
+
session = this.createSession(page, ownsPage, principal, context, record, lifetime);
|
|
275
|
+
session.renderLifetime = renderer;
|
|
276
|
+
const config = {
|
|
243
277
|
pageId: session.id,
|
|
244
278
|
socketPath: this.paths.socket,
|
|
245
279
|
runtimePath: this.paths.runtime,
|
|
246
280
|
version: PROTOCOL_VERSION,
|
|
247
|
-
}
|
|
281
|
+
};
|
|
282
|
+
if (renderer.enabled) {
|
|
283
|
+
session.renderer = renderer;
|
|
284
|
+
renderer.document = value => document(value, config);
|
|
285
|
+
renderer.onError = error => this.logger.error?.('Live HTML reactive render failed.', error);
|
|
286
|
+
} else renderer.nodes.clear();
|
|
287
|
+
return deliver(document(markup, config));
|
|
248
288
|
} catch (error) {
|
|
249
|
-
|
|
289
|
+
renderer?.dispose();
|
|
290
|
+
if (ownsPage && page) {
|
|
291
|
+
if (lifetime.signal.aborted) await withinDeadline(Promise.allSettled([page.dispose()]), Date.now() + this.shutdownTimeoutMs);
|
|
292
|
+
else await page.dispose();
|
|
293
|
+
}
|
|
294
|
+
if (this.closing) throw new Error('Live HTML server is shutting down.');
|
|
250
295
|
throw error;
|
|
251
296
|
} finally {
|
|
297
|
+
signal?.removeEventListener('abort', lifetime.abort);
|
|
298
|
+
if (!session) { lifetime.abort(); this.lifetimes.delete(lifetime); }
|
|
252
299
|
if (page) this.renderPages.delete(page);
|
|
253
300
|
this.rendering -= 1;
|
|
254
301
|
if (live) this.liveRendering -= 1;
|
|
@@ -256,9 +303,10 @@ class PageManager {
|
|
|
256
303
|
}
|
|
257
304
|
}
|
|
258
305
|
|
|
259
|
-
createSession(page, ownsPage, principal) {
|
|
306
|
+
createSession(page, ownsPage, principal, context = {}, record, lifetime = this.createLifetime(principal)) {
|
|
260
307
|
const id = randomUUID();
|
|
261
|
-
const session = { id, page, ownsPage, principal, socket: null, timer: null, detaching: null };
|
|
308
|
+
const session = { id, page, ownsPage, principal, context, record, lifetime, socket: null, timer: null, detaching: null };
|
|
309
|
+
lifetime.session = session;
|
|
262
310
|
this.pending.set(id, session);
|
|
263
311
|
this.expire(session);
|
|
264
312
|
return session;
|
|
@@ -282,28 +330,33 @@ class PageManager {
|
|
|
282
330
|
if (typeof id !== 'string' || id.length > 128) return false;
|
|
283
331
|
const session = this.pending.get(id) || this.active.get(id);
|
|
284
332
|
if (!session || session.socket || session.detaching) return false;
|
|
285
|
-
|
|
286
|
-
const principal = await this.
|
|
333
|
+
try {
|
|
334
|
+
const principal = await this.identity.resolve(request, session.lifetime.signal);
|
|
287
335
|
if (!Object.is(principal, session.principal)) return false;
|
|
336
|
+
await session.record?.metadata.policy?.check(session.context);
|
|
337
|
+
return this.available(session) && !session.socket && !session.detaching ? session : false;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (error instanceof AuthenticationFailure || error instanceof AccessDenied) throw error;
|
|
340
|
+
throw new Error('Page upgrade failed.');
|
|
288
341
|
}
|
|
289
|
-
return !session.socket && !session.detaching && !LivePage.isDisposed(session.page) ? session : false;
|
|
290
342
|
}
|
|
291
343
|
|
|
292
344
|
acceptsOrigin(origin, request) {
|
|
293
345
|
if (typeof origin !== 'string') return false;
|
|
346
|
+
let parsed;
|
|
294
347
|
try {
|
|
295
|
-
|
|
296
|
-
if (typeof this.origins === 'function') return this.origins(origin, request);
|
|
297
|
-
if (this.origins) return this.origins.includes(parsed.origin);
|
|
298
|
-
const protocol = request.socket?.encrypted ? 'https:' : 'http:';
|
|
299
|
-
return parsed.protocol === protocol && parsed.host === request.headers.host;
|
|
348
|
+
parsed = new URL(origin);
|
|
300
349
|
} catch {
|
|
301
350
|
return false;
|
|
302
351
|
}
|
|
352
|
+
if (typeof this.origins === 'function') return this.origins(origin, request);
|
|
353
|
+
if (this.origins) return this.origins.includes(parsed.origin);
|
|
354
|
+
const protocol = request.socket?.encrypted ? 'https:' : 'http:';
|
|
355
|
+
return parsed.protocol === protocol && parsed.host === request.headers.host;
|
|
303
356
|
}
|
|
304
357
|
|
|
305
358
|
connect(session, socket) {
|
|
306
|
-
if (!session || session
|
|
359
|
+
if (!session || !this.available(session) || session.socket || session.detaching) {
|
|
307
360
|
throw new Error('Page session is unavailable.');
|
|
308
361
|
}
|
|
309
362
|
clearTimeout(session.timer);
|
|
@@ -311,32 +364,100 @@ class PageManager {
|
|
|
311
364
|
this.active.set(session.id, session);
|
|
312
365
|
session.socket = socket;
|
|
313
366
|
socket.__redwebPageSession = session;
|
|
314
|
-
|
|
367
|
+
session.connection = new PageLifetime(session.lifetime.signal);
|
|
368
|
+
socket.once?.('close', session.connection.abort);
|
|
369
|
+
socket.context?.signal?.addEventListener('abort', session.connection.abort, { once: true });
|
|
370
|
+
if (socket.context?.signal?.aborted || (socket.readyState !== undefined && socket.readyState !== 1)) session.connection.abort();
|
|
371
|
+
if (socket.context?.signal) setMaxListeners(this.maxSessions + 1, socket.context.signal);
|
|
372
|
+
return session.connection.wait(() => session.page._attach(socket, this.connectionContext(session, socket)))
|
|
373
|
+
.then(result => {
|
|
374
|
+
this.checkConnected(session, socket);
|
|
375
|
+
return session.renderer ? session.renderer.attach(socket, LivePage.snapshots(session.page, true)) : result;
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
available(session) {
|
|
380
|
+
return !session.lifetime.revoked && !LivePage.isDisposed(session.page) && (this.pending.get(session.id) === session || this.active.get(session.id) === session);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
connectionContext(session, socket) { return Object.freeze({ ...session.context, socket, signal: session.connection.signal, principal: session.principal }); }
|
|
384
|
+
|
|
385
|
+
checkConnected(session, socket) {
|
|
386
|
+
if (!this.available(session) || session.socket !== socket) throw new AccessDenied('ACCESS_CANCELLED');
|
|
387
|
+
session.connection.check();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async authorize(session, socket) {
|
|
391
|
+
this.checkConnected(session, socket);
|
|
392
|
+
await session.record?.metadata.policy?.check(this.connectionContext(session, socket));
|
|
393
|
+
this.checkConnected(session, socket);
|
|
315
394
|
}
|
|
316
395
|
|
|
317
396
|
async disconnect(socket) {
|
|
318
397
|
const session = socket.__redwebPageSession;
|
|
319
398
|
if (!session || session.socket !== socket) return false;
|
|
320
|
-
|
|
399
|
+
session.connection.abort();
|
|
400
|
+
socket.off?.('close', session.connection.abort);
|
|
401
|
+
socket.context?.signal?.removeEventListener('abort', session.connection.abort);
|
|
402
|
+
session.renderer?.detach();
|
|
403
|
+
const detaching = Promise.resolve(session.page._detach(socket, this.connectionContext(session, socket)))
|
|
321
404
|
.finally(() => { session.detaching = null; });
|
|
322
405
|
session.detaching = detaching;
|
|
323
406
|
session.socket = null;
|
|
324
|
-
this.expire(session);
|
|
407
|
+
if (this.available(session)) this.expire(session);
|
|
325
408
|
await detaching;
|
|
326
409
|
return true;
|
|
327
410
|
}
|
|
328
411
|
|
|
329
412
|
async release(session) {
|
|
413
|
+
session.lifetime.revoked = true;
|
|
414
|
+
const cleanup = [];
|
|
415
|
+
if (session.socket) {
|
|
416
|
+
session.socket.terminate?.();
|
|
417
|
+
cleanup.push(this.disconnect(session.socket));
|
|
418
|
+
} else if (session.detaching) cleanup.push(session.detaching);
|
|
419
|
+
session.lifetime.abort();
|
|
420
|
+
this.lifetimes.delete(session.lifetime);
|
|
421
|
+
session.renderLifetime?.dispose();
|
|
330
422
|
clearTimeout(session.timer);
|
|
331
423
|
this.pending.delete(session.id);
|
|
332
424
|
this.active.delete(session.id);
|
|
333
|
-
if (session.ownsPage)
|
|
425
|
+
if (session.ownsPage) cleanup.push(session.page.dispose());
|
|
426
|
+
LivePage._throwLifecycleFailures(await Promise.allSettled(cleanup), 'Page session cleanup failed.');
|
|
334
427
|
return true;
|
|
335
428
|
}
|
|
336
429
|
|
|
430
|
+
async revoke(principal) {
|
|
431
|
+
if (!isPrincipal(principal)) throw new TypeError('revoke() requires an authenticated primitive identity.');
|
|
432
|
+
const affected = [...this.lifetimes].filter(lifetime => Object.is(lifetime.principal, principal) || (this.authenticateRequest && lifetime.principal === undefined));
|
|
433
|
+
// Invalidate every publication target before dispatching any application abort listener.
|
|
434
|
+
affected.forEach(lifetime => { lifetime.revoked = true; });
|
|
435
|
+
affected.forEach(lifetime => lifetime.session?.socket?.terminate?.());
|
|
436
|
+
const cleanup = [];
|
|
437
|
+
for (const lifetime of affected) {
|
|
438
|
+
const session = lifetime.session;
|
|
439
|
+
if (session) {
|
|
440
|
+
cleanup.push(this.release(session));
|
|
441
|
+
} else {
|
|
442
|
+
lifetime.abort();
|
|
443
|
+
this.lifetimes.delete(lifetime);
|
|
444
|
+
if (lifetime.page) cleanup.push(lifetime.page.dispose());
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!cleanup.length) return affected.length;
|
|
448
|
+
const result = await withinDeadline(Promise.allSettled(cleanup), Date.now() + this.shutdownTimeoutMs);
|
|
449
|
+
if (!result.completed || result.value.some(entry => entry.status === 'rejected')) {
|
|
450
|
+
const error = new Error('Page access revoked; application cleanup did not complete successfully.');
|
|
451
|
+
error.code = 'REVOCATION_CLEANUP_FAILED';
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
return affected.length;
|
|
455
|
+
}
|
|
456
|
+
|
|
337
457
|
async receive(socket, message) {
|
|
338
458
|
const session = socket.__redwebPageSession;
|
|
339
459
|
if (!session) throw new Error('Page session is not connected.');
|
|
460
|
+
this.checkConnected(session, socket);
|
|
340
461
|
const payload = message.payload;
|
|
341
462
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new TypeError('Live HTML payload must be an object.');
|
|
342
463
|
const name = boundedName(payload.name, 'Live HTML member name');
|
|
@@ -345,17 +466,16 @@ class PageManager {
|
|
|
345
466
|
: session.page._component(boundedName(payload.component, 'Live HTML component name'));
|
|
346
467
|
if (!target) throw new Error('Unknown Live HTML component.');
|
|
347
468
|
if (payload.kind === 'action') {
|
|
348
|
-
const result = await LivePage.invoke(target, name, payload.args,
|
|
349
|
-
|
|
350
|
-
signal: socket.context?.signal,
|
|
351
|
-
principal: session.principal,
|
|
352
|
-
}));
|
|
469
|
+
const result = await LivePage.invoke(target, name, payload.args, this.connectionContext(session, socket), () => this.authorize(session, socket));
|
|
470
|
+
this.checkConnected(session, socket);
|
|
353
471
|
if (message.requestId !== undefined) {
|
|
354
472
|
socket.sendEvent('redweb:result', result ?? null, { requestId: message.requestId });
|
|
355
473
|
}
|
|
356
474
|
return;
|
|
357
475
|
}
|
|
358
476
|
if (payload.kind === 'state') {
|
|
477
|
+
await this.authorize(session, socket);
|
|
478
|
+
this.checkConnected(session, socket);
|
|
359
479
|
LivePage.setFromClient(target, name, payload.value);
|
|
360
480
|
return;
|
|
361
481
|
}
|
|
@@ -367,7 +487,14 @@ class PageManager {
|
|
|
367
487
|
class LiveHtmlHandler extends BaseHandler {
|
|
368
488
|
constructor() { super('redweb:html'); }
|
|
369
489
|
onInitialContact(socket) { return manager.connect(socket.context.principal, socket); }
|
|
370
|
-
onMessage(socket, message) {
|
|
490
|
+
async onMessage(socket, message) {
|
|
491
|
+
try { return await manager.receive(socket, message); }
|
|
492
|
+
catch (error) {
|
|
493
|
+
const failure = RequestFailure.from(error);
|
|
494
|
+
if (!failure.code.startsWith('ACTION_') && !failure.code.startsWith('ACCESS_')) throw error;
|
|
495
|
+
socket.sendProtocolError(failure.code, failure.message, { requestId: message.requestId });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
371
498
|
}
|
|
372
499
|
return class LiveHtmlRoute extends SocketRoute {
|
|
373
500
|
constructor() {
|
|
@@ -432,4 +559,6 @@ class PageManager {
|
|
|
432
559
|
}
|
|
433
560
|
}
|
|
434
561
|
|
|
562
|
+
PageManager.prototype.Renderer = ReactiveRenderer;
|
|
563
|
+
|
|
435
564
|
module.exports = { DEFAULT_PATHS, PROTOCOL_VERSION, PageManager };
|