redweb 0.9.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 +17 -0
- package/README.md +138 -23
- package/docs/LIVE_HTML.md +313 -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 +219 -1
- package/index.js +18 -1
- package/package.json +14 -3
- 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 +0 -35
- package/src/ws/BaseSocketServer.js +4 -0
- package/src/htmx/HtmxRenderer.js +0 -73
- package/src/htmx/RedWebHtmxComponent.js +0 -11
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const RAW_TEXT = new Set(['iframe', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'textarea', 'title', 'xmp']);
|
|
2
|
+
|
|
3
|
+
function isHtmlSpace(character) {
|
|
4
|
+
return character === ' ' || character === '\t' || character === '\n' || character === '\f' || character === '\r';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isNonStartMarkup(source, start) {
|
|
8
|
+
const marker = source[start + 1];
|
|
9
|
+
if (marker === '!' || marker === '?') return true;
|
|
10
|
+
return marker === '/' && /[A-Za-z]/.test(source[start + 2]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function equalsAsciiCaseInsensitive(value, expected) {
|
|
14
|
+
if (value.length !== expected.length) return false;
|
|
15
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
16
|
+
const code = value.charCodeAt(index);
|
|
17
|
+
const folded = code >= 65 && code <= 90 ? code + 32 : code;
|
|
18
|
+
if (folded !== expected.charCodeAt(index)) return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function scanTag(source, position) {
|
|
24
|
+
let state = 'beforeAttribute';
|
|
25
|
+
let quote;
|
|
26
|
+
let attributeName = null;
|
|
27
|
+
let attributeStart = -1;
|
|
28
|
+
for (; position < source.length; position += 1) {
|
|
29
|
+
const character = source[position];
|
|
30
|
+
if (state === 'quotedValue') {
|
|
31
|
+
if (character === quote) state = 'beforeAttribute';
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (character === '>') return { end: position, state, attributeName };
|
|
35
|
+
if (state === 'beforeValue') {
|
|
36
|
+
if (isHtmlSpace(character)) continue;
|
|
37
|
+
if (character === '"' || character === "'") {
|
|
38
|
+
quote = character;
|
|
39
|
+
state = 'quotedValue';
|
|
40
|
+
} else state = 'unquotedValue';
|
|
41
|
+
} else if (state === 'beforeAttribute') {
|
|
42
|
+
if (!isHtmlSpace(character) && character !== '/') {
|
|
43
|
+
attributeStart = position;
|
|
44
|
+
state = 'attributeName';
|
|
45
|
+
}
|
|
46
|
+
} else if (state === 'attributeName') {
|
|
47
|
+
if (character === '=') {
|
|
48
|
+
attributeName = source.slice(attributeStart, position).toLowerCase();
|
|
49
|
+
state = 'beforeValue';
|
|
50
|
+
} else if (isHtmlSpace(character)) {
|
|
51
|
+
attributeName = source.slice(attributeStart, position).toLowerCase();
|
|
52
|
+
state = 'afterAttributeName';
|
|
53
|
+
}
|
|
54
|
+
} else if (state === 'afterAttributeName') {
|
|
55
|
+
if (character === '=') state = 'beforeValue';
|
|
56
|
+
else if (!isHtmlSpace(character) && character !== '/') {
|
|
57
|
+
attributeStart = position;
|
|
58
|
+
attributeName = null;
|
|
59
|
+
state = 'attributeName';
|
|
60
|
+
}
|
|
61
|
+
} else if (isHtmlSpace(character)) state = 'beforeAttribute';
|
|
62
|
+
}
|
|
63
|
+
return { end: -1, state, attributeName };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function tagEnd(source, position) {
|
|
67
|
+
return scanTag(source, position).end;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rawClosingTag(source, name, position) {
|
|
71
|
+
while (true) {
|
|
72
|
+
const start = source.indexOf('</', position);
|
|
73
|
+
if (start < 0) return null;
|
|
74
|
+
const candidate = source.slice(start + 2, start + 2 + name.length);
|
|
75
|
+
const boundary = source[start + 2 + name.length];
|
|
76
|
+
if (equalsAsciiCaseInsensitive(candidate, name) && (boundary === '>' || boundary === '/' || isHtmlSpace(boundary))) {
|
|
77
|
+
const end = tagEnd(source, start + 2 + name.length);
|
|
78
|
+
if (end >= 0) return { start, end: end + 1 };
|
|
79
|
+
}
|
|
80
|
+
position = start + 2;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function tagLocation(source, target, kind) {
|
|
85
|
+
let position = 0;
|
|
86
|
+
while (position < source.length) {
|
|
87
|
+
const start = source.indexOf('<', position);
|
|
88
|
+
if (start < 0) return -1;
|
|
89
|
+
if (source.startsWith('<!--', start)) {
|
|
90
|
+
const commentEnd = source.indexOf('-->', start + 4);
|
|
91
|
+
position = commentEnd < 0 ? source.length : commentEnd + 3;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const recognizedMarkup = isNonStartMarkup(source, start);
|
|
95
|
+
if (!recognizedMarkup && !/[A-Za-z]/.test(source[start + 1])) {
|
|
96
|
+
position = start + 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const end = tagEnd(source, start + 1);
|
|
100
|
+
if (end < 0) return -1;
|
|
101
|
+
const tag = source.slice(start, end + 1);
|
|
102
|
+
const closing = /^<\/([A-Za-z][\w:-]*)/i.exec(tag)?.[1]?.toLowerCase();
|
|
103
|
+
if (kind === 'closing' && closing === target) return start;
|
|
104
|
+
const opening = /^<([A-Za-z][\w:-]*)/i.exec(tag)?.[1]?.toLowerCase();
|
|
105
|
+
if (kind === 'opening' && opening === target) return start;
|
|
106
|
+
if (opening && RAW_TEXT.has(opening)) {
|
|
107
|
+
if (opening === 'plaintext') return -1;
|
|
108
|
+
const close = rawClosingTag(source, opening, end + 1);
|
|
109
|
+
position = close ? close.end : source.length;
|
|
110
|
+
} else {
|
|
111
|
+
position = end + 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return -1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function interpolationContext(source) {
|
|
118
|
+
let position = 0;
|
|
119
|
+
while (position < source.length) {
|
|
120
|
+
const start = source.indexOf('<', position);
|
|
121
|
+
if (start < 0) return { kind: 'text' };
|
|
122
|
+
if (source.startsWith('<!--', start)) {
|
|
123
|
+
const commentEnd = source.indexOf('-->', start + 4);
|
|
124
|
+
if (commentEnd < 0) return { kind: 'protected' };
|
|
125
|
+
position = commentEnd + 3;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const opening = /^<([A-Za-z][\w:-]*)/.exec(source.slice(start));
|
|
129
|
+
if (opening) {
|
|
130
|
+
const name = opening[1].toLowerCase();
|
|
131
|
+
const scanned = scanTag(source, start + opening[0].length);
|
|
132
|
+
if (scanned.end < 0) {
|
|
133
|
+
if (scanned.state === 'quotedValue' && scanned.attributeName) {
|
|
134
|
+
return { kind: 'attribute', name: scanned.attributeName };
|
|
135
|
+
}
|
|
136
|
+
return { kind: 'protected' };
|
|
137
|
+
}
|
|
138
|
+
if (RAW_TEXT.has(name)) {
|
|
139
|
+
if (name === 'plaintext') return { kind: 'protected' };
|
|
140
|
+
const close = rawClosingTag(source, name, scanned.end + 1);
|
|
141
|
+
if (!close) return { kind: 'protected' };
|
|
142
|
+
position = close.end;
|
|
143
|
+
} else {
|
|
144
|
+
position = scanned.end + 1;
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (isNonStartMarkup(source, start)) {
|
|
149
|
+
const end = tagEnd(source, start + 1);
|
|
150
|
+
if (end < 0) return { kind: 'protected' };
|
|
151
|
+
position = end + 1;
|
|
152
|
+
} else {
|
|
153
|
+
position = start + 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { kind: 'text' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
RAW_TEXT,
|
|
161
|
+
closingTag: (source, target) => tagLocation(source, target, 'closing'),
|
|
162
|
+
interpolationContext,
|
|
163
|
+
isHtmlSpace,
|
|
164
|
+
isNonStartMarkup,
|
|
165
|
+
openingTag: (source, target) => tagLocation(source, target, 'opening'),
|
|
166
|
+
rawClosingTag,
|
|
167
|
+
tagEnd,
|
|
168
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const HttpServer = require('../http/HttpServer');
|
|
3
|
+
const HttpsServer = require('../http/HttpsServer');
|
|
4
|
+
const SocketServer = require('../ws/SocketServer');
|
|
5
|
+
const { PageManager } = require('./PageManager');
|
|
6
|
+
|
|
7
|
+
class LiveHtmlServer {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
10
|
+
throw new TypeError('Live HTML server options must be an object.');
|
|
11
|
+
}
|
|
12
|
+
const {
|
|
13
|
+
pages,
|
|
14
|
+
templateRoot,
|
|
15
|
+
livePaths,
|
|
16
|
+
sessionTtlMs,
|
|
17
|
+
maxSessions,
|
|
18
|
+
maxConcurrentRenders,
|
|
19
|
+
shutdownTimeoutMs = 1000,
|
|
20
|
+
heartbeat,
|
|
21
|
+
authenticate,
|
|
22
|
+
origins,
|
|
23
|
+
server: suppliedApp,
|
|
24
|
+
...httpOptions
|
|
25
|
+
} = options;
|
|
26
|
+
const app = suppliedApp === undefined ? express() : suppliedApp;
|
|
27
|
+
if (!app || typeof app.get !== 'function' || typeof app.use !== 'function') {
|
|
28
|
+
throw new TypeError('`server` must be an Express-compatible application.');
|
|
29
|
+
}
|
|
30
|
+
this.manager = new PageManager({
|
|
31
|
+
pages,
|
|
32
|
+
templateRoot,
|
|
33
|
+
paths: livePaths,
|
|
34
|
+
sessionTtlMs,
|
|
35
|
+
maxSessions,
|
|
36
|
+
maxConcurrentRenders,
|
|
37
|
+
shutdownTimeoutMs,
|
|
38
|
+
heartbeat,
|
|
39
|
+
authenticate,
|
|
40
|
+
origins,
|
|
41
|
+
logger: httpOptions.logger,
|
|
42
|
+
});
|
|
43
|
+
this.manager.mount(app);
|
|
44
|
+
const listen = httpOptions.listen ?? true;
|
|
45
|
+
const ServerClass = httpOptions.ssl ? HttpsServer : HttpServer;
|
|
46
|
+
this.http = new ServerClass({ ...httpOptions, server: app, listen: this.manager.hasLivePages ? false : listen });
|
|
47
|
+
if (this.manager.hasLivePages) {
|
|
48
|
+
const Route = this.manager.route();
|
|
49
|
+
this.sockets = new SocketServer({
|
|
50
|
+
server: this.http.server,
|
|
51
|
+
routes: [Route],
|
|
52
|
+
listen,
|
|
53
|
+
port: this.http.port,
|
|
54
|
+
bind: this.http.bind,
|
|
55
|
+
listenCallback: this.http.listenCallback,
|
|
56
|
+
logger: this.http.logger,
|
|
57
|
+
closeServerOnShutdown: false,
|
|
58
|
+
});
|
|
59
|
+
} else {
|
|
60
|
+
this.sockets = null;
|
|
61
|
+
}
|
|
62
|
+
this.app = this.http.app;
|
|
63
|
+
this.server = this.http.server;
|
|
64
|
+
this._shutdownPromise = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
shutdown() {
|
|
68
|
+
if (!this._shutdownPromise) {
|
|
69
|
+
this._shutdownPromise = this.performShutdown();
|
|
70
|
+
}
|
|
71
|
+
return this._shutdownPromise;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async performShutdown() {
|
|
75
|
+
const errors = [];
|
|
76
|
+
if (this.sockets) {
|
|
77
|
+
try { await this.sockets.shutdown(); }
|
|
78
|
+
catch (error) { errors.push(error); }
|
|
79
|
+
}
|
|
80
|
+
try { await this.manager.shutdown(); }
|
|
81
|
+
catch (error) {
|
|
82
|
+
errors.push(error);
|
|
83
|
+
if (error?.code === 'LIVE_HTML_SHUTDOWN_TIMEOUT') this.server.closeAllConnections?.();
|
|
84
|
+
}
|
|
85
|
+
try { await this.http.shutdown(); }
|
|
86
|
+
catch (error) { errors.push(error); }
|
|
87
|
+
if (errors.length) throw new AggregateError(errors, 'Live HTML shutdown failed.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = LiveHtmlServer;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
2
|
+
const HtmlRenderer = require('./HtmlRenderer');
|
|
3
|
+
const TemplateRenderer = require('./TemplateRenderer');
|
|
4
|
+
const { isHtml, markHtml, renderValue } = require('./Html');
|
|
5
|
+
const { forEachState, getActionImplementation, getStateConfig, isComponentClass } = require('./metadata');
|
|
6
|
+
|
|
7
|
+
const RUNTIME = new WeakMap();
|
|
8
|
+
const COMPONENT_RENDER_CONTEXT = new AsyncLocalStorage();
|
|
9
|
+
const RUNTIME_METHODS = Object.freeze([
|
|
10
|
+
'_activateState',
|
|
11
|
+
'_component',
|
|
12
|
+
'_loadComponents',
|
|
13
|
+
'_attach',
|
|
14
|
+
'_detach',
|
|
15
|
+
'_stateChanged',
|
|
16
|
+
'_setFromClient',
|
|
17
|
+
'_invoke',
|
|
18
|
+
'dispose',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function initializeRuntime(page) {
|
|
22
|
+
RUNTIME.set(page, {
|
|
23
|
+
connections: new Set(),
|
|
24
|
+
disposed: false,
|
|
25
|
+
stateActive: false,
|
|
26
|
+
stateValues: new Map(),
|
|
27
|
+
disposePromise: null,
|
|
28
|
+
children: new Map(),
|
|
29
|
+
componentId: null,
|
|
30
|
+
components: new Map(),
|
|
31
|
+
root: page,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runtime(page) {
|
|
36
|
+
return RUNTIME.get(page);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class LivePage {
|
|
40
|
+
constructor() {
|
|
41
|
+
initializeRuntime(this);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static adopt(page) {
|
|
45
|
+
if (!page || typeof page !== 'object') throw new TypeError('Page construction must return an object.');
|
|
46
|
+
if (RUNTIME.has(page)) return page;
|
|
47
|
+
if (!(page instanceof LivePage)) {
|
|
48
|
+
RUNTIME_METHODS.forEach(name => {
|
|
49
|
+
if (name in page) throw new TypeError(`Plain page classes cannot define reserved member "${name}".`);
|
|
50
|
+
Object.defineProperty(page, name, {
|
|
51
|
+
configurable: false,
|
|
52
|
+
enumerable: false,
|
|
53
|
+
writable: false,
|
|
54
|
+
value: LivePage.prototype[name],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
initializeRuntime(page);
|
|
59
|
+
return page;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static isDisposed(page) {
|
|
63
|
+
return runtime(page).disposed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static activate(page) { return LivePage.prototype._activateState.call(page); }
|
|
67
|
+
static attach(page, socket, context) { return LivePage.prototype._attach.call(page, socket, context); }
|
|
68
|
+
static detach(page, socket, context) { return LivePage.prototype._detach.call(page, socket, context); }
|
|
69
|
+
static dispose(page) { return LivePage.prototype.dispose.call(page); }
|
|
70
|
+
static invoke(page, name, args, context) { return LivePage.prototype._invoke.call(page, name, args, context); }
|
|
71
|
+
static loadComponents(page, context) { return LivePage.prototype._loadComponents.call(page, context); }
|
|
72
|
+
static setFromClient(page, name, value) { return LivePage.prototype._setFromClient.call(page, name, value); }
|
|
73
|
+
|
|
74
|
+
static statePayload(page, name, value) {
|
|
75
|
+
const internal = runtime(page);
|
|
76
|
+
const payload = HtmlRenderer.statePayload(name, value, page);
|
|
77
|
+
if (!internal.componentId) return payload;
|
|
78
|
+
payload.component = internal.componentId;
|
|
79
|
+
if (payload.html) payload.value = TemplateRenderer.component(payload.value, internal.componentId);
|
|
80
|
+
return payload;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static withRenderContext(context, render) {
|
|
84
|
+
return COMPONENT_RENDER_CONTEXT.run(context, render);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static adoptComponent(owner, name, value) {
|
|
88
|
+
if (!value || typeof value !== 'object' || !isComponentClass(value.constructor)) return null;
|
|
89
|
+
if (!/^[A-Za-z_$][\w$-]{0,127}$/.test(name) || ['__proto__', 'prototype', 'constructor'].includes(name)) {
|
|
90
|
+
throw new TypeError(`Component field "${name}" must be a safe identifier of at most 128 characters.`);
|
|
91
|
+
}
|
|
92
|
+
const parent = runtime(owner);
|
|
93
|
+
const component = value;
|
|
94
|
+
if (!RUNTIME.has(component)) initializeRuntime(component);
|
|
95
|
+
const internal = runtime(component);
|
|
96
|
+
const id = parent.componentId ? `${parent.componentId}.${name}` : name;
|
|
97
|
+
if (id.length > 128) throw new TypeError('Nested component identifiers must be at most 128 characters.');
|
|
98
|
+
if (internal.componentId && (internal.componentId !== id || internal.root !== parent.root)) {
|
|
99
|
+
throw new Error('A component instance can belong to only one component field.');
|
|
100
|
+
}
|
|
101
|
+
internal.componentId = id;
|
|
102
|
+
internal.root = parent.root;
|
|
103
|
+
parent.children.set(name, component);
|
|
104
|
+
runtime(parent.root).components.set(id, component);
|
|
105
|
+
if (!isHtml(component)) markHtml(component, LivePage.prototype._renderComponent);
|
|
106
|
+
LivePage.activate(component);
|
|
107
|
+
return component;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get _connections() { return runtime(this).connections; }
|
|
111
|
+
get _disposed() { return runtime(this).disposed; }
|
|
112
|
+
get _disposePromise() { return runtime(this).disposePromise; }
|
|
113
|
+
|
|
114
|
+
_activateState() {
|
|
115
|
+
const internal = runtime(this);
|
|
116
|
+
if (internal.stateActive) return false;
|
|
117
|
+
forEachState(this.constructor, (_options, name) => {
|
|
118
|
+
internal.stateValues.set(name, this[name]);
|
|
119
|
+
Object.defineProperty(this, name, {
|
|
120
|
+
configurable: true,
|
|
121
|
+
enumerable: true,
|
|
122
|
+
get: () => internal.stateValues.get(name),
|
|
123
|
+
set: value => {
|
|
124
|
+
const previous = internal.stateValues.get(name);
|
|
125
|
+
internal.stateValues.set(name, value);
|
|
126
|
+
if (previous !== value) LivePage.prototype._stateChanged.call(this, name, value);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
internal.stateActive = true;
|
|
131
|
+
Object.keys(this).forEach(name => {
|
|
132
|
+
const value = this[name];
|
|
133
|
+
if (isComponentClass(value?.constructor) && getStateConfig(this.constructor, name)) {
|
|
134
|
+
throw new TypeError(`Component field "${name}" cannot also be decorated with state().`);
|
|
135
|
+
}
|
|
136
|
+
LivePage.adoptComponent(this, name, value);
|
|
137
|
+
});
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_renderComponent() {
|
|
142
|
+
const internal = runtime(this);
|
|
143
|
+
if (!internal.componentId) throw new Error('Components must be owned by a page field before rendering.');
|
|
144
|
+
const source = this.render?.(COMPONENT_RENDER_CONTEXT.getStore());
|
|
145
|
+
if (source && typeof source.then === 'function') throw new TypeError('Component render() must be synchronous.');
|
|
146
|
+
if (source === undefined) throw new Error(`${this.constructor.name || 'Component'} must provide render().`);
|
|
147
|
+
const markup = isHtml(source) ? renderValue(source) : HtmlRenderer.render(source.toString(), this);
|
|
148
|
+
return TemplateRenderer.component(markup, internal.componentId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_component(id) {
|
|
152
|
+
return runtime(this).root === this ? runtime(this).components.get(id) : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async _loadComponents(context) {
|
|
156
|
+
for (const component of runtime(this).children.values()) {
|
|
157
|
+
await component.loading?.(context);
|
|
158
|
+
await LivePage.loadComponents(component, context);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_attach(socket, context) {
|
|
163
|
+
const internal = runtime(this);
|
|
164
|
+
if (internal.disposed) throw new Error('Cannot connect a disposed page.');
|
|
165
|
+
internal.connections.add(socket);
|
|
166
|
+
forEachState(this.constructor, (_options, name) => {
|
|
167
|
+
const payload = LivePage.statePayload(this, name, this[name]);
|
|
168
|
+
socket.sendEvent?.('redweb:state', payload);
|
|
169
|
+
});
|
|
170
|
+
const connected = this.connected?.(context);
|
|
171
|
+
return Promise.resolve(connected).then(async result => {
|
|
172
|
+
for (const component of internal.children.values()) {
|
|
173
|
+
await LivePage.attach(component, socket, context);
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async _detach(socket, context) {
|
|
180
|
+
const internal = runtime(this);
|
|
181
|
+
const removed = internal.connections.delete(socket);
|
|
182
|
+
if (!removed) return false;
|
|
183
|
+
const tasks = [...internal.children.values()].reverse().map(component => LivePage.detach(component, socket, context));
|
|
184
|
+
tasks.push(Promise.resolve().then(() => this.disconnected?.(context)));
|
|
185
|
+
const results = await Promise.allSettled(tasks);
|
|
186
|
+
LivePage._throwLifecycleFailures(results, 'Live HTML component disconnect failed.');
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_stateChanged(name, value) {
|
|
191
|
+
if (!getStateConfig(this.constructor, name)) return false;
|
|
192
|
+
const payload = LivePage.statePayload(this, name, value);
|
|
193
|
+
runtime(this).connections.forEach(socket => socket.sendEvent?.('redweb:state', payload));
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_setFromClient(name, value) {
|
|
198
|
+
const config = getStateConfig(this.constructor, name);
|
|
199
|
+
if (!config?.writable) throw new Error(`State "${name}" is not browser-writable.`);
|
|
200
|
+
this[name] = value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async _invoke(name, args, context) {
|
|
204
|
+
const implementation = getActionImplementation(this.constructor, name);
|
|
205
|
+
if (!implementation || this[name] !== implementation) throw new Error(`Unknown page action "${name}".`);
|
|
206
|
+
if (!Array.isArray(args)) throw new TypeError('Action arguments must be an array.');
|
|
207
|
+
return implementation.call(this, ...args, context);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async dispose() {
|
|
211
|
+
const internal = runtime(this);
|
|
212
|
+
if (internal.disposePromise) return internal.disposePromise;
|
|
213
|
+
internal.disposed = true;
|
|
214
|
+
internal.connections.clear();
|
|
215
|
+
internal.disposePromise = Promise.resolve().then(async () => {
|
|
216
|
+
const tasks = [...internal.children.values()].map(component => LivePage.dispose(component));
|
|
217
|
+
tasks.push(Promise.resolve().then(() => this.disposed?.()));
|
|
218
|
+
const results = await Promise.allSettled(tasks);
|
|
219
|
+
LivePage._throwLifecycleFailures(results, 'Live HTML component cleanup failed.');
|
|
220
|
+
return true;
|
|
221
|
+
});
|
|
222
|
+
return internal.disposePromise;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
static _throwLifecycleFailures(results, message) {
|
|
226
|
+
const failures = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
227
|
+
if (failures.length === 1) throw failures[0];
|
|
228
|
+
if (failures.length > 1) throw new AggregateError(failures, message);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = LivePage;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function outside(root, candidate) {
|
|
5
|
+
const relative = path.relative(root, candidate);
|
|
6
|
+
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class PageAssetLoader {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.cache = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
load(filePath, rootDir, kind) {
|
|
15
|
+
const root = path.resolve(rootDir);
|
|
16
|
+
const resolved = path.resolve(root, filePath);
|
|
17
|
+
if (outside(root, resolved)) throw new Error(`Page ${kind} is outside the configured template root.`);
|
|
18
|
+
if (!fs.existsSync(resolved)) throw new Error(`Page ${kind} not found: ${resolved}`);
|
|
19
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
20
|
+
const canonicalFile = fs.realpathSync(resolved);
|
|
21
|
+
if (outside(canonicalRoot, canonicalFile)) {
|
|
22
|
+
throw new Error(`Page ${kind} is outside the configured template root.`);
|
|
23
|
+
}
|
|
24
|
+
if (!this.cache.has(canonicalFile)) {
|
|
25
|
+
this.cache.set(canonicalFile, Object.freeze({
|
|
26
|
+
path: canonicalFile,
|
|
27
|
+
content: fs.readFileSync(canonicalFile, 'utf8'),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
return this.cache.get(canonicalFile);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = PageAssetLoader;
|