redweb 0.8.0 → 0.10.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 +28 -0
- package/README.md +573 -307
- package/client.d.ts +42 -0
- package/client.js +55 -0
- package/docs/LIVE_HTML.md +313 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
- package/docs/PRODUCTION_READINESS.md +68 -0
- package/docs/VERIFICATION_EVIDENCE.md +20 -0
- package/examples/live-html/cards.css +36 -0
- package/examples/live-html/cards.html +11 -0
- package/examples/live-html/cards.js +91 -0
- package/examples/live-html/cards.ts +35 -0
- package/examples/live-html/chatroom.css +156 -0
- package/examples/live-html/chatroom.js +268 -0
- package/examples/live-html/chatroom.ts +217 -0
- package/examples/live-html/components.css +7 -0
- package/examples/live-html/components.js +113 -0
- package/examples/live-html/components.ts +41 -0
- package/examples/live-html/counter.css +24 -0
- package/examples/live-html/counter.html +10 -0
- package/examples/live-html/counter.js +73 -0
- package/examples/live-html/counter.ts +21 -0
- package/examples/live-html/tsconfig.json +16 -0
- package/index.d.ts +538 -114
- package/index.js +44 -12
- package/package.json +39 -15
- package/src/htmx/Html.js +133 -0
- package/src/htmx/HtmlRenderer.js +88 -0
- package/src/htmx/HtmlSyntax.js +168 -0
- package/src/htmx/LiveHtmlServer.js +91 -0
- package/src/htmx/LivePage.js +232 -0
- package/src/htmx/PageAssetLoader.js +34 -0
- package/src/htmx/PageManager.js +435 -0
- package/src/htmx/StaticExporter.js +78 -0
- package/src/htmx/StaticSite.js +182 -0
- package/src/htmx/TemplateRenderer.js +231 -0
- package/src/htmx/browserRuntime.js +97 -0
- package/src/htmx/index.js +10 -0
- package/src/htmx/metadata.js +349 -0
- package/src/htmx/sourceRoot.js +28 -0
- package/src/htmx/start.js +17 -0
- package/src/htmx/synchronous.js +9 -0
- package/src/http/BaseHttpServer.js +82 -117
- package/src/http/HttpServer.js +18 -18
- package/src/http/HttpsServer.js +20 -20
- package/src/serverLifecycle.js +46 -46
- package/src/ws/AdmissionPolicy.js +145 -0
- package/src/ws/BaseHandler.js +40 -40
- package/src/ws/BaseSocketServer.js +199 -100
- package/src/ws/DefaultHandler.js +5 -5
- package/src/ws/DefaultRoute.js +8 -8
- package/src/ws/DistributionBridge.js +271 -0
- package/src/ws/FixedStepService.js +74 -0
- package/src/ws/HeartbeatMonitor.js +75 -0
- package/src/ws/Metrics.js +34 -0
- package/src/ws/ProtocolPolicy.js +130 -0
- package/src/ws/RoomRegistry.js +117 -0
- package/src/ws/RouteRuntime.js +146 -0
- package/src/ws/SecureSocketServer.js +9 -9
- package/src/ws/SessionRegistry.js +135 -0
- package/src/ws/SocketRoute.js +523 -254
- package/src/ws/SocketServer.js +8 -8
- package/src/ws/TaskQueue.js +64 -0
- package/src/ws/TokenBucket.js +31 -0
- package/src/ws/TransportPolicy.js +68 -0
- package/src/ws/index.js +7 -2
- package/src/ws/protocol-schema.json +13 -0
- package/src/ws/protocol-validation.js +21 -0
- package/src/ws/shutdown.js +33 -33
- package/src/ws/util.js +38 -30
- package/src/htmx/HtmxRenderer.js +0 -73
- package/src/htmx/RedWebHtmxComponent.js +0 -11
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
const { createHash, randomUUID } = require('crypto');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { BaseHandler } = require('../ws/BaseHandler');
|
|
4
|
+
const { SocketRoute } = require('../ws');
|
|
5
|
+
const HtmlRenderer = require('./HtmlRenderer');
|
|
6
|
+
const PageAssetLoader = require('./PageAssetLoader');
|
|
7
|
+
const LivePage = require('./LivePage');
|
|
8
|
+
const browserRuntime = require('./browserRuntime');
|
|
9
|
+
const { isHtml, renderValue, trustedHtml } = require('./Html');
|
|
10
|
+
const { getPageMetadata, getPageStylesheetRoots, getPageTemplateRoot } = require('./metadata');
|
|
11
|
+
const synchronous = require('./synchronous');
|
|
12
|
+
|
|
13
|
+
const PROTOCOL_VERSION = '1';
|
|
14
|
+
const DEFAULT_HEARTBEAT = Object.freeze({ intervalMs: 15_000, timeoutMs: 10_000 });
|
|
15
|
+
const DEFAULT_PATHS = Object.freeze({
|
|
16
|
+
socket: '/__redweb/live',
|
|
17
|
+
client: '/__redweb/client.js',
|
|
18
|
+
runtime: '/__redweb/runtime.js',
|
|
19
|
+
css: '/__redweb/css',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function boundedName(value, label) {
|
|
23
|
+
if (typeof value !== 'string' || !value || value.length > 128 || ['__proto__', 'prototype', 'constructor'].includes(value)) {
|
|
24
|
+
throw new TypeError(`${label} must be a safe non-empty string of at most 128 characters.`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function internalPath(value, label) {
|
|
30
|
+
if (typeof value !== 'string' || !/^\/(?!\/)(?!.*\/\/)(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2}|\/)+$/.test(value)) {
|
|
31
|
+
throw new TypeError(`${label} path must be an absolute URL pathname using safe characters.`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function withinDeadline(promise, deadline) {
|
|
37
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
38
|
+
if (remaining === 0) return Promise.resolve({ completed: false });
|
|
39
|
+
return new Promise(resolve => {
|
|
40
|
+
const timer = setTimeout(() => resolve({ completed: false }), remaining);
|
|
41
|
+
promise.then(value => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
resolve({ completed: true, value });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cacheControl(cache = {}) {
|
|
49
|
+
const directives = ['public', `max-age=${cache.maxAge ?? 0}`];
|
|
50
|
+
if ((cache.staleWhileRevalidate ?? 0) > 0) directives.push(`stale-while-revalidate=${cache.staleWhileRevalidate}`);
|
|
51
|
+
if (cache.immutable) directives.push('immutable');
|
|
52
|
+
else if ((cache.maxAge ?? 0) === 0) directives.push('must-revalidate');
|
|
53
|
+
return directives.join(', ');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchesIfNoneMatch(header, etag) {
|
|
57
|
+
if (typeof header !== 'string') return false;
|
|
58
|
+
return header.split(',').some(value => {
|
|
59
|
+
const candidate = value.trim();
|
|
60
|
+
return candidate === '*' || candidate.replace(/^W\//i, '') === etag;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class PageManager {
|
|
65
|
+
constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, heartbeat = DEFAULT_HEARTBEAT, authenticate, origins, logger = console }) {
|
|
66
|
+
if (!Array.isArray(pages) || pages.length === 0) throw new TypeError('`pages` must be a non-empty array.');
|
|
67
|
+
if (templateRoot !== undefined && (typeof templateRoot !== 'string' || !templateRoot)) throw new TypeError('`templateRoot` must be a non-empty string.');
|
|
68
|
+
if (!Number.isInteger(sessionTtlMs) || sessionTtlMs < 0) throw new TypeError('`sessionTtlMs` must be a non-negative integer.');
|
|
69
|
+
if (!Number.isInteger(maxSessions) || maxSessions < 1) throw new TypeError('`maxSessions` must be a positive integer.');
|
|
70
|
+
if (!Number.isInteger(maxConcurrentRenders) || maxConcurrentRenders < 1) {
|
|
71
|
+
throw new TypeError('`maxConcurrentRenders` must be a positive integer.');
|
|
72
|
+
}
|
|
73
|
+
if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
|
|
74
|
+
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
|
+
if (origins !== undefined && typeof origins !== 'function' &&
|
|
77
|
+
(!Array.isArray(origins) || origins.some(origin => typeof origin !== 'string' || !origin))) {
|
|
78
|
+
throw new TypeError('`origins` must be a function or an array of non-empty origins.');
|
|
79
|
+
}
|
|
80
|
+
this.paths = { ...DEFAULT_PATHS, ...paths };
|
|
81
|
+
Object.entries(this.paths).forEach(([name, value]) => {
|
|
82
|
+
internalPath(value, name);
|
|
83
|
+
});
|
|
84
|
+
if (new Set(Object.values(this.paths)).size !== Object.values(this.paths).length) {
|
|
85
|
+
throw new Error('Live HTML internal paths must be unique.');
|
|
86
|
+
}
|
|
87
|
+
if (this.paths.css.endsWith('/')) {
|
|
88
|
+
throw new TypeError('Live HTML css path must be a URL prefix without a trailing slash.');
|
|
89
|
+
}
|
|
90
|
+
if (Object.entries(this.paths).some(([name, value]) => name !== 'css' && value.startsWith(`${this.paths.css}/`))) {
|
|
91
|
+
throw new Error('Live HTML css path must not contain another internal path.');
|
|
92
|
+
}
|
|
93
|
+
this.templateRoot = path.resolve(templateRoot || process.cwd());
|
|
94
|
+
this.hasExplicitTemplateRoot = templateRoot !== undefined;
|
|
95
|
+
this.sessionTtlMs = sessionTtlMs;
|
|
96
|
+
this.maxSessions = maxSessions;
|
|
97
|
+
this.maxConcurrentRenders = maxConcurrentRenders;
|
|
98
|
+
this.shutdownTimeoutMs = shutdownTimeoutMs;
|
|
99
|
+
this.heartbeat = heartbeat;
|
|
100
|
+
this.logger = logger || { log() {}, warn() {}, error() {} };
|
|
101
|
+
this.authenticateRequest = authenticate;
|
|
102
|
+
this.origins = origins;
|
|
103
|
+
this.pending = new Map();
|
|
104
|
+
this.active = new Map();
|
|
105
|
+
this.records = new Map();
|
|
106
|
+
this.stylesheets = new Map();
|
|
107
|
+
this.stylesheetUrls = new Map();
|
|
108
|
+
this.assets = new PageAssetLoader();
|
|
109
|
+
this.sharedPages = new Set();
|
|
110
|
+
this.rendering = 0;
|
|
111
|
+
this.liveRendering = 0;
|
|
112
|
+
this.renderWaiters = [];
|
|
113
|
+
this.renderPages = new Set();
|
|
114
|
+
this.renderAbortController = new AbortController();
|
|
115
|
+
this.closing = false;
|
|
116
|
+
pages.forEach(PageClass => this.register(PageClass));
|
|
117
|
+
this.hasLivePages = [...this.records.values()].some(record => record.metadata.live !== false);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
register(PageClass) {
|
|
121
|
+
if (typeof PageClass !== 'function') throw new TypeError('Every page must be a class.');
|
|
122
|
+
const metadata = getPageMetadata(PageClass);
|
|
123
|
+
if (!metadata) throw new TypeError(`${PageClass.name || 'Page'} is missing @page metadata.`);
|
|
124
|
+
if (this.records.has(metadata.path) || Object.values(this.paths).includes(metadata.path) || metadata.path.startsWith(`${this.paths.css}/`)) {
|
|
125
|
+
throw new Error(`Duplicate or reserved Live HTML path: ${metadata.path}`);
|
|
126
|
+
}
|
|
127
|
+
const root = this.hasExplicitTemplateRoot ? this.templateRoot : getPageTemplateRoot(PageClass);
|
|
128
|
+
const record = {
|
|
129
|
+
PageClass,
|
|
130
|
+
metadata,
|
|
131
|
+
template: metadata.template ? this.assets.load(metadata.template, root, 'template').content : null,
|
|
132
|
+
stylesheets: [...new Set((metadata.css || []).map((file, index) => this.registerStylesheet(
|
|
133
|
+
file,
|
|
134
|
+
(this.hasExplicitTemplateRoot ? undefined : getPageStylesheetRoots(PageClass)?.[index]) || root,
|
|
135
|
+
)))],
|
|
136
|
+
shared: null,
|
|
137
|
+
};
|
|
138
|
+
if (metadata.scope === 'shared') {
|
|
139
|
+
record.shared = this.instantiate(record);
|
|
140
|
+
this.sharedPages.add(record.shared);
|
|
141
|
+
}
|
|
142
|
+
this.records.set(metadata.path, record);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
registerStylesheet(file, root) {
|
|
146
|
+
const asset = this.assets.load(file, root, 'stylesheet');
|
|
147
|
+
const existing = this.stylesheetUrls.get(asset.path);
|
|
148
|
+
if (existing) return existing;
|
|
149
|
+
const digest = createHash('sha256').update(asset.content).digest('hex');
|
|
150
|
+
const url = `${this.paths.css}/${digest}.css`;
|
|
151
|
+
this.stylesheets.set(url, asset.content);
|
|
152
|
+
this.stylesheetUrls.set(asset.path, url);
|
|
153
|
+
return url;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
instantiate(record) {
|
|
157
|
+
const instance = new record.PageClass();
|
|
158
|
+
if (!(instance instanceof record.PageClass)) throw new TypeError('Page construction returned an incompatible object.');
|
|
159
|
+
const page = LivePage.adopt(instance);
|
|
160
|
+
page._activateState();
|
|
161
|
+
return page;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
mount(app) {
|
|
165
|
+
if (this.hasLivePages) {
|
|
166
|
+
const clientFile = path.join(path.dirname(require.resolve('redweb-client')), 'index.js');
|
|
167
|
+
app.get(this.paths.client, (_request, response) => response.sendFile(clientFile));
|
|
168
|
+
app.get(this.paths.runtime, (_request, response) => response.type('text/javascript').send(browserRuntime(this.paths.client)));
|
|
169
|
+
}
|
|
170
|
+
this.stylesheets.forEach((content, url) => app.get(url, (_request, response) => {
|
|
171
|
+
response.set('Cache-Control', 'public, max-age=31536000, immutable').type('text/css').send(content);
|
|
172
|
+
}));
|
|
173
|
+
this.records.forEach(record => app.get(record.metadata.path, (request, response, next) => {
|
|
174
|
+
Promise.resolve(this.render(record, request)).then(markup => {
|
|
175
|
+
if (record.metadata.live !== false) {
|
|
176
|
+
response.set('Cache-Control', 'private, no-store').type('html').send(markup);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (this.authenticateRequest) {
|
|
180
|
+
response.set('Cache-Control', 'private, no-store').type('html').send(markup);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const etag = `"${createHash('sha256').update(markup).digest('base64url')}"`;
|
|
184
|
+
response.set('Cache-Control', cacheControl(record.metadata.cache)).set('ETag', etag);
|
|
185
|
+
if (matchesIfNoneMatch(request.headers['if-none-match'], etag)) {
|
|
186
|
+
response.status(304).end();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
response.type('html').send(markup);
|
|
190
|
+
}, next);
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async render(record, request) {
|
|
195
|
+
const live = record.metadata.live !== false;
|
|
196
|
+
const sessionsFull = live && this.pending.size + this.active.size + this.liveRendering >= this.maxSessions;
|
|
197
|
+
if (this.closing || this.rendering >= this.maxConcurrentRenders || sessionsFull) {
|
|
198
|
+
const error = new Error('Live HTML session capacity reached.');
|
|
199
|
+
error.status = 503;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
this.rendering += 1;
|
|
203
|
+
if (live) this.liveRendering += 1;
|
|
204
|
+
const ownsPage = record.metadata.scope === 'connection';
|
|
205
|
+
let page;
|
|
206
|
+
try {
|
|
207
|
+
page = ownsPage ? this.instantiate(record) : record.shared;
|
|
208
|
+
this.renderPages.add(page);
|
|
209
|
+
const principal = this.authenticateRequest ? await this.authenticateRequest(request) : undefined;
|
|
210
|
+
if (this.authenticateRequest && (principal === false || principal === null || principal === undefined || typeof principal === 'object')) {
|
|
211
|
+
const error = new Error('Live HTML authentication failed.');
|
|
212
|
+
error.status = 401;
|
|
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 () => {
|
|
227
|
+
const source = record.template ?? await page.render?.(context);
|
|
228
|
+
if (this.closing) throw new Error('Live HTML server is shutting down.');
|
|
229
|
+
if (source === undefined) throw new Error(`${record.PageClass.name} must provide a template or render().`);
|
|
230
|
+
const content = isHtml(source) ? renderValue(source) : HtmlRenderer.render(source.toString(), page, { live });
|
|
231
|
+
if (!record.metadata.layout) return content;
|
|
232
|
+
const result = synchronous(record.metadata.layout(trustedHtml(content), context), 'Page layouts must render synchronously.');
|
|
233
|
+
if (!isHtml(result)) throw new TypeError('Page layouts must return html.');
|
|
234
|
+
return renderValue(result);
|
|
235
|
+
});
|
|
236
|
+
if (record.metadata.live === false) {
|
|
237
|
+
const document = HtmlRenderer.document(markup, null, record.stylesheets, record.metadata.head);
|
|
238
|
+
if (ownsPage) await page.dispose();
|
|
239
|
+
return document;
|
|
240
|
+
}
|
|
241
|
+
const session = this.createSession(page, ownsPage, principal);
|
|
242
|
+
return HtmlRenderer.document(markup, {
|
|
243
|
+
pageId: session.id,
|
|
244
|
+
socketPath: this.paths.socket,
|
|
245
|
+
runtimePath: this.paths.runtime,
|
|
246
|
+
version: PROTOCOL_VERSION,
|
|
247
|
+
}, record.stylesheets, record.metadata.head);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (ownsPage && page) await page.dispose();
|
|
250
|
+
throw error;
|
|
251
|
+
} finally {
|
|
252
|
+
if (page) this.renderPages.delete(page);
|
|
253
|
+
this.rendering -= 1;
|
|
254
|
+
if (live) this.liveRendering -= 1;
|
|
255
|
+
if (this.rendering === 0) this.renderWaiters.splice(0).forEach(resolve => resolve());
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
createSession(page, ownsPage, principal) {
|
|
260
|
+
const id = randomUUID();
|
|
261
|
+
const session = { id, page, ownsPage, principal, socket: null, timer: null, detaching: null };
|
|
262
|
+
this.pending.set(id, session);
|
|
263
|
+
this.expire(session);
|
|
264
|
+
return session;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
expire(session) {
|
|
268
|
+
clearTimeout(session.timer);
|
|
269
|
+
session.timer = setTimeout(() => {
|
|
270
|
+
this.release(session).catch(error => this.logger.error?.('Live HTML session cleanup failed.', error));
|
|
271
|
+
}, this.sessionTtlMs);
|
|
272
|
+
session.timer.unref?.();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async authenticate(request) {
|
|
276
|
+
let id;
|
|
277
|
+
try {
|
|
278
|
+
id = new URL(request.url, `http://${request.headers.host || 'localhost'}`).searchParams.get('pageId');
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
if (typeof id !== 'string' || id.length > 128) return false;
|
|
283
|
+
const session = this.pending.get(id) || this.active.get(id);
|
|
284
|
+
if (!session || session.socket || session.detaching) return false;
|
|
285
|
+
if (this.authenticateRequest) {
|
|
286
|
+
const principal = await this.authenticateRequest(request);
|
|
287
|
+
if (!Object.is(principal, session.principal)) return false;
|
|
288
|
+
}
|
|
289
|
+
return !session.socket && !session.detaching && !LivePage.isDisposed(session.page) ? session : false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
acceptsOrigin(origin, request) {
|
|
293
|
+
if (typeof origin !== 'string') return false;
|
|
294
|
+
try {
|
|
295
|
+
const parsed = new URL(origin);
|
|
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;
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
connect(session, socket) {
|
|
306
|
+
if (!session || session.socket || session.detaching || LivePage.isDisposed(session.page)) {
|
|
307
|
+
throw new Error('Page session is unavailable.');
|
|
308
|
+
}
|
|
309
|
+
clearTimeout(session.timer);
|
|
310
|
+
this.pending.delete(session.id);
|
|
311
|
+
this.active.set(session.id, session);
|
|
312
|
+
session.socket = socket;
|
|
313
|
+
socket.__redwebPageSession = session;
|
|
314
|
+
return session.page._attach(socket, Object.freeze({ socket, signal: socket.context?.signal, principal: session.principal }));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async disconnect(socket) {
|
|
318
|
+
const session = socket.__redwebPageSession;
|
|
319
|
+
if (!session || session.socket !== socket) return false;
|
|
320
|
+
const detaching = Promise.resolve(session.page._detach(socket, Object.freeze({ socket })))
|
|
321
|
+
.finally(() => { session.detaching = null; });
|
|
322
|
+
session.detaching = detaching;
|
|
323
|
+
session.socket = null;
|
|
324
|
+
this.expire(session);
|
|
325
|
+
await detaching;
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async release(session) {
|
|
330
|
+
clearTimeout(session.timer);
|
|
331
|
+
this.pending.delete(session.id);
|
|
332
|
+
this.active.delete(session.id);
|
|
333
|
+
if (session.ownsPage) await session.page.dispose();
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async receive(socket, message) {
|
|
338
|
+
const session = socket.__redwebPageSession;
|
|
339
|
+
if (!session) throw new Error('Page session is not connected.');
|
|
340
|
+
const payload = message.payload;
|
|
341
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new TypeError('Live HTML payload must be an object.');
|
|
342
|
+
const name = boundedName(payload.name, 'Live HTML member name');
|
|
343
|
+
const target = payload.component === undefined || payload.component === null
|
|
344
|
+
? session.page
|
|
345
|
+
: session.page._component(boundedName(payload.component, 'Live HTML component name'));
|
|
346
|
+
if (!target) throw new Error('Unknown Live HTML component.');
|
|
347
|
+
if (payload.kind === 'action') {
|
|
348
|
+
const result = await LivePage.invoke(target, name, payload.args, Object.freeze({
|
|
349
|
+
socket,
|
|
350
|
+
signal: socket.context?.signal,
|
|
351
|
+
principal: session.principal,
|
|
352
|
+
}));
|
|
353
|
+
if (message.requestId !== undefined) {
|
|
354
|
+
socket.sendEvent('redweb:result', result ?? null, { requestId: message.requestId });
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (payload.kind === 'state') {
|
|
359
|
+
LivePage.setFromClient(target, name, payload.value);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
throw new TypeError('Live HTML message kind must be "action" or "state".');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
route() {
|
|
366
|
+
const manager = this;
|
|
367
|
+
class LiveHtmlHandler extends BaseHandler {
|
|
368
|
+
constructor() { super('redweb:html'); }
|
|
369
|
+
onInitialContact(socket) { return manager.connect(socket.context.principal, socket); }
|
|
370
|
+
onMessage(socket, message) { return manager.receive(socket, message); }
|
|
371
|
+
}
|
|
372
|
+
return class LiveHtmlRoute extends SocketRoute {
|
|
373
|
+
constructor() {
|
|
374
|
+
super({
|
|
375
|
+
path: manager.paths.socket,
|
|
376
|
+
handlers: [LiveHtmlHandler],
|
|
377
|
+
allowDuplicateConnections: true,
|
|
378
|
+
orderedMessages: true,
|
|
379
|
+
drainHandlers: true,
|
|
380
|
+
heartbeat: manager.heartbeat,
|
|
381
|
+
shutdownTimeoutMs: manager.shutdownTimeoutMs,
|
|
382
|
+
limits: { maxPendingMessages: 64, maxBufferedBytes: 256 * 1024 },
|
|
383
|
+
websocketOptions: { maxPayload: 64 * 1024 },
|
|
384
|
+
protocol: { versions: [PROTOCOL_VERSION] },
|
|
385
|
+
admission: {
|
|
386
|
+
origins: (origin, request) => manager.acceptsOrigin(origin, request),
|
|
387
|
+
authenticate: request => manager.authenticate(request),
|
|
388
|
+
},
|
|
389
|
+
logger: manager.logger,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
connectionCloseCallback(socket) { return manager.disconnect(socket); }
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async shutdown() {
|
|
397
|
+
this.closing = true;
|
|
398
|
+
this.renderAbortController.abort();
|
|
399
|
+
const errors = [];
|
|
400
|
+
const deadline = Date.now() + this.shutdownTimeoutMs;
|
|
401
|
+
if (this.rendering > 0) {
|
|
402
|
+
const drained = new Promise(resolve => this.renderWaiters.push(resolve));
|
|
403
|
+
const drain = await withinDeadline(drained, deadline);
|
|
404
|
+
if (!drain.completed) {
|
|
405
|
+
const timeout = new Error('Live HTML render cleanup exceeded shutdownTimeoutMs.');
|
|
406
|
+
timeout.code = 'LIVE_HTML_SHUTDOWN_TIMEOUT';
|
|
407
|
+
errors.push(timeout);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const cleanup = Promise.allSettled([
|
|
411
|
+
...[...this.pending.values(), ...this.active.values()].map(session => this.release(session)),
|
|
412
|
+
...[...this.sharedPages].map(page => page.dispose()),
|
|
413
|
+
...[...this.renderPages].map(page => page.dispose()),
|
|
414
|
+
]);
|
|
415
|
+
this.sharedPages.clear();
|
|
416
|
+
this.renderPages.clear();
|
|
417
|
+
const cleanupResult = await withinDeadline(cleanup, deadline);
|
|
418
|
+
if (cleanupResult.completed) {
|
|
419
|
+
errors.push(...cleanupResult.value.filter(result => result.status === 'rejected').map(result => result.reason));
|
|
420
|
+
} else {
|
|
421
|
+
const timeout = new Error('Live HTML page disposal exceeded shutdownTimeoutMs.');
|
|
422
|
+
timeout.code = 'LIVE_HTML_SHUTDOWN_TIMEOUT';
|
|
423
|
+
errors.push(timeout);
|
|
424
|
+
}
|
|
425
|
+
if (errors.length) {
|
|
426
|
+
const aggregate = new AggregateError(errors, 'Live HTML page cleanup failed.');
|
|
427
|
+
if (errors.some(error => error?.code === 'LIVE_HTML_SHUTDOWN_TIMEOUT')) {
|
|
428
|
+
aggregate.code = 'LIVE_HTML_SHUTDOWN_TIMEOUT';
|
|
429
|
+
}
|
|
430
|
+
throw aggregate;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
module.exports = { DEFAULT_PATHS, PROTOCOL_VERSION, PageManager };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { PageManager } = require('./PageManager');
|
|
4
|
+
const { getPageMetadata } = require('./metadata');
|
|
5
|
+
|
|
6
|
+
const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
7
|
+
|
|
8
|
+
function pageFile(outDir, route) {
|
|
9
|
+
const segments = route.split('/').filter(Boolean);
|
|
10
|
+
if (!/^\/(?:[A-Za-z0-9._~-]+\/?)*$/.test(route) ||
|
|
11
|
+
segments.some(part => part === '.' || part === '..' || part.endsWith('.') || WINDOWS_RESERVED.test(part))) {
|
|
12
|
+
throw new TypeError(`Static page path cannot be exported: ${route}`);
|
|
13
|
+
}
|
|
14
|
+
const relative = route === '/' ? 'index.html' : path.join(route.slice(1), 'index.html');
|
|
15
|
+
return path.join(outDir, relative);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function write(file, content) {
|
|
19
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
20
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function exportStatic(pageOrPages, options = {}) {
|
|
24
|
+
const pages = Array.isArray(pageOrPages) ? pageOrPages : [pageOrPages];
|
|
25
|
+
if (!pages.length || pages.some(PageClass => typeof PageClass !== 'function')) {
|
|
26
|
+
throw new TypeError('exportStatic() requires a page class or a non-empty array of page classes.');
|
|
27
|
+
}
|
|
28
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
29
|
+
throw new TypeError('exportStatic() options must be an object.');
|
|
30
|
+
}
|
|
31
|
+
const { outDir, templateRoot, logger = console } = options;
|
|
32
|
+
if (typeof outDir !== 'string' || !outDir) throw new TypeError('exportStatic() requires a non-empty outDir.');
|
|
33
|
+
const root = path.resolve(outDir);
|
|
34
|
+
const pageFiles = new Set();
|
|
35
|
+
const preflight = pages.map(PageClass => {
|
|
36
|
+
const metadata = getPageMetadata(PageClass);
|
|
37
|
+
if (!metadata) throw new TypeError(`${PageClass.name || 'Page'} is missing @page metadata.`);
|
|
38
|
+
if (metadata.live !== false) throw new Error(`Static export requires live: false on ${PageClass.name}.`);
|
|
39
|
+
const file = pageFile(root, metadata.path);
|
|
40
|
+
const key = path.relative(root, file).replaceAll('\\', '/').toLowerCase();
|
|
41
|
+
if (pageFiles.has(key)) throw new Error(`Static page paths resolve to the same output file: ${file}`);
|
|
42
|
+
pageFiles.add(key);
|
|
43
|
+
return { metadata, file };
|
|
44
|
+
});
|
|
45
|
+
const manager = new PageManager({ pages, templateRoot, logger });
|
|
46
|
+
try {
|
|
47
|
+
const pagePlan = preflight.map(({ metadata, file }) => ({ record: manager.records.get(metadata.path), file }));
|
|
48
|
+
const assetPlan = [...manager.stylesheets].map(([url, content]) => ({
|
|
49
|
+
file: path.join(root, ...url.slice(1).split('/')),
|
|
50
|
+
content,
|
|
51
|
+
}));
|
|
52
|
+
const renderedPages = [];
|
|
53
|
+
for (const entry of pagePlan) {
|
|
54
|
+
const { record, file } = entry;
|
|
55
|
+
const request = Object.freeze({
|
|
56
|
+
path: record.metadata.path,
|
|
57
|
+
url: record.metadata.path,
|
|
58
|
+
method: 'GET',
|
|
59
|
+
headers: Object.freeze({}),
|
|
60
|
+
params: Object.freeze({}),
|
|
61
|
+
query: Object.freeze({}),
|
|
62
|
+
body: undefined,
|
|
63
|
+
get: () => undefined,
|
|
64
|
+
});
|
|
65
|
+
renderedPages.push({ file, content: await manager.render(record, request) });
|
|
66
|
+
}
|
|
67
|
+
renderedPages.forEach(entry => write(entry.file, entry.content));
|
|
68
|
+
assetPlan.forEach(entry => write(entry.file, entry.content));
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
pages: Object.freeze(renderedPages.map(entry => entry.file)),
|
|
71
|
+
assets: Object.freeze(assetPlan.map(entry => entry.file)),
|
|
72
|
+
});
|
|
73
|
+
} finally {
|
|
74
|
+
await manager.shutdown();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { exportStatic };
|