redweb 0.9.0 → 0.11.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 +22 -0
- package/README.md +187 -23
- package/docs/LIVE_HTML.md +352 -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/jsx-page.js +81 -0
- package/examples/live-html/jsx-page.tsx +41 -0
- package/examples/live-html/tsconfig.json +19 -0
- package/index.d.ts +219 -1
- package/index.js +18 -1
- package/jsx-dev-runtime.d.ts +13 -0
- package/jsx-dev-runtime.js +9 -0
- package/jsx-runtime.d.ts +28 -0
- package/jsx-runtime.js +5 -0
- package/package.json +47 -3
- package/src/htmx/Html.js +137 -0
- package/src/htmx/HtmlRenderer.js +88 -0
- package/src/htmx/HtmlSyntax.js +168 -0
- package/src/htmx/Jsx.js +86 -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,349 @@
|
|
|
1
|
+
const PAGE_METADATA = new WeakMap();
|
|
2
|
+
const STATE_METADATA = new WeakMap();
|
|
3
|
+
const ACTION_METADATA = new WeakMap();
|
|
4
|
+
const RESOLVED_STATE = new WeakMap();
|
|
5
|
+
const RESOLVED_ACTION = new WeakMap();
|
|
6
|
+
const STANDARD_ACTIONS = new WeakMap();
|
|
7
|
+
const VIEW_METADATA = new WeakMap();
|
|
8
|
+
const RESOLVED_VIEW = new WeakMap();
|
|
9
|
+
const STANDARD_VIEWS = new WeakMap();
|
|
10
|
+
const PAGE_ROOTS = new WeakMap();
|
|
11
|
+
const PAGE_STYLESHEET_ROOTS = new WeakMap();
|
|
12
|
+
const COMPONENT_CLASSES = new WeakSet();
|
|
13
|
+
const { decoratorDirectory } = require('./sourceRoot');
|
|
14
|
+
const synchronous = require('./synchronous');
|
|
15
|
+
let metadataVersion = 0;
|
|
16
|
+
|
|
17
|
+
function assertDecoratorTarget(target, label) {
|
|
18
|
+
if (!target || typeof target !== 'object' || typeof target.constructor !== 'function') {
|
|
19
|
+
throw new TypeError(`${label} must decorate a class member.`);
|
|
20
|
+
}
|
|
21
|
+
return target.constructor;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hierarchy(PageClass) {
|
|
25
|
+
const classes = [];
|
|
26
|
+
for (let current = PageClass; typeof current === 'function' && current !== Function.prototype; current = Object.getPrototypeOf(current)) {
|
|
27
|
+
classes.unshift(current);
|
|
28
|
+
}
|
|
29
|
+
return classes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function registerState(PageClass, property, config) {
|
|
33
|
+
const properties = new Map(STATE_METADATA.get(PageClass) || []);
|
|
34
|
+
const existing = properties.get(property);
|
|
35
|
+
if (existing?.writable === config.writable) return;
|
|
36
|
+
properties.set(property, config);
|
|
37
|
+
STATE_METADATA.set(PageClass, properties);
|
|
38
|
+
metadataVersion += 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function registerAction(PageClass, method, implementation) {
|
|
42
|
+
const methods = new Map(ACTION_METADATA.get(PageClass) || []);
|
|
43
|
+
if (methods.get(method) === implementation) return;
|
|
44
|
+
methods.set(method, implementation);
|
|
45
|
+
ACTION_METADATA.set(PageClass, methods);
|
|
46
|
+
metadataVersion += 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function registerStandardAction(PageClass, method, implementation) {
|
|
50
|
+
const methods = new Map(STANDARD_ACTIONS.get(PageClass) || []);
|
|
51
|
+
if (methods.get(method) === implementation) return;
|
|
52
|
+
methods.set(method, implementation);
|
|
53
|
+
STANDARD_ACTIONS.set(PageClass, methods);
|
|
54
|
+
metadataVersion += 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function registerView(store, PageClass, stateName, method, implementation) {
|
|
58
|
+
const views = new Map(store.get(PageClass) || []);
|
|
59
|
+
const existing = views.get(stateName);
|
|
60
|
+
if (existing?.method === method && existing.implementation === implementation) return;
|
|
61
|
+
views.set(stateName, Object.freeze({ method, implementation }));
|
|
62
|
+
store.set(PageClass, views);
|
|
63
|
+
metadataVersion += 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolvedState(PageClass) {
|
|
67
|
+
const cached = RESOLVED_STATE.get(PageClass);
|
|
68
|
+
if (cached?.version === metadataVersion) return cached.value;
|
|
69
|
+
const value = new Map();
|
|
70
|
+
hierarchy(PageClass).forEach(CurrentClass => {
|
|
71
|
+
STATE_METADATA.get(CurrentClass)?.forEach((config, property) => value.set(property, config));
|
|
72
|
+
});
|
|
73
|
+
RESOLVED_STATE.set(PageClass, { version: metadataVersion, value });
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolvedAction(PageClass) {
|
|
78
|
+
const cached = RESOLVED_ACTION.get(PageClass);
|
|
79
|
+
if (cached?.version === metadataVersion) return cached.value;
|
|
80
|
+
const value = new Map();
|
|
81
|
+
hierarchy(PageClass).forEach(CurrentClass => {
|
|
82
|
+
const own = new Map(ACTION_METADATA.get(CurrentClass) || []);
|
|
83
|
+
STANDARD_ACTIONS.get(CurrentClass)?.forEach((implementation, method) => {
|
|
84
|
+
if (CurrentClass.prototype[method] === implementation) own.set(method, implementation);
|
|
85
|
+
});
|
|
86
|
+
value.forEach((_implementation, method) => {
|
|
87
|
+
if (Object.prototype.hasOwnProperty.call(CurrentClass.prototype, method) && !own.has(method)) value.delete(method);
|
|
88
|
+
});
|
|
89
|
+
own.forEach((implementation, method) => value.set(method, implementation));
|
|
90
|
+
});
|
|
91
|
+
RESOLVED_ACTION.set(PageClass, { version: metadataVersion, value });
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolvedView(PageClass) {
|
|
96
|
+
const cached = RESOLVED_VIEW.get(PageClass);
|
|
97
|
+
if (cached?.version === metadataVersion) return cached.value;
|
|
98
|
+
const value = new Map();
|
|
99
|
+
hierarchy(PageClass).forEach(CurrentClass => {
|
|
100
|
+
const own = new Map(VIEW_METADATA.get(CurrentClass) || []);
|
|
101
|
+
STANDARD_VIEWS.get(CurrentClass)?.forEach((entry, stateName) => {
|
|
102
|
+
if (CurrentClass.prototype[entry.method] === entry.implementation) own.set(stateName, entry);
|
|
103
|
+
});
|
|
104
|
+
value.forEach((entry, stateName) => {
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(CurrentClass.prototype, entry.method) && !own.has(stateName)) value.delete(stateName);
|
|
106
|
+
});
|
|
107
|
+
own.forEach((entry, stateName) => value.set(stateName, entry));
|
|
108
|
+
});
|
|
109
|
+
RESOLVED_VIEW.set(PageClass, { version: metadataVersion, value });
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pageHead(value) {
|
|
114
|
+
if (value === undefined) return undefined;
|
|
115
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Page head must be an object.');
|
|
116
|
+
const allowed = new Set(['title', 'description', 'canonical', 'image', 'robots']);
|
|
117
|
+
const unknown = Object.keys(value).find(name => !allowed.has(name));
|
|
118
|
+
if (unknown) throw new TypeError(`Unknown page head option: ${unknown}.`);
|
|
119
|
+
const head = {};
|
|
120
|
+
for (const name of ['title', 'description', 'robots']) {
|
|
121
|
+
if (value[name] !== undefined && (typeof value[name] !== 'string' || !value[name])) {
|
|
122
|
+
throw new TypeError(`Page head ${name} must be a non-empty string.`);
|
|
123
|
+
}
|
|
124
|
+
if (value[name] !== undefined) head[name] = value[name];
|
|
125
|
+
}
|
|
126
|
+
for (const name of ['canonical', 'image']) {
|
|
127
|
+
if (value[name] === undefined) continue;
|
|
128
|
+
if (typeof value[name] !== 'string' || !value[name]) throw new TypeError(`Page head ${name} must be an absolute HTTP(S) URL.`);
|
|
129
|
+
let parsed;
|
|
130
|
+
try { parsed = new URL(value[name]); }
|
|
131
|
+
catch { throw new TypeError(`Page head ${name} must be an absolute HTTP(S) URL.`); }
|
|
132
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) throw new TypeError(`Page head ${name} must be an absolute HTTP(S) URL.`);
|
|
133
|
+
head[name] = parsed.href;
|
|
134
|
+
}
|
|
135
|
+
return Object.freeze(head);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pageCache(value, live) {
|
|
139
|
+
if (value === undefined) return undefined;
|
|
140
|
+
if (live) throw new TypeError('Page cache is available only when live is false.');
|
|
141
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Page cache must be an object.');
|
|
142
|
+
const allowed = new Set(['maxAge', 'staleWhileRevalidate', 'immutable']);
|
|
143
|
+
const unknown = Object.keys(value).find(name => !allowed.has(name));
|
|
144
|
+
if (unknown) throw new TypeError(`Unknown page cache option: ${unknown}.`);
|
|
145
|
+
const { maxAge = 0, staleWhileRevalidate = 0, immutable = false } = value;
|
|
146
|
+
if (!Number.isInteger(maxAge) || maxAge < 0) throw new TypeError('Page cache maxAge must be a non-negative integer.');
|
|
147
|
+
if (!Number.isInteger(staleWhileRevalidate) || staleWhileRevalidate < 0) {
|
|
148
|
+
throw new TypeError('Page cache staleWhileRevalidate must be a non-negative integer.');
|
|
149
|
+
}
|
|
150
|
+
if (typeof immutable !== 'boolean') throw new TypeError('Page cache immutable must be a boolean.');
|
|
151
|
+
return Object.freeze({ maxAge, staleWhileRevalidate, immutable });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function page(routePath, options = {}) {
|
|
155
|
+
const templateRoot = decoratorDirectory();
|
|
156
|
+
if (typeof routePath !== 'string' || !routePath.startsWith('/')) {
|
|
157
|
+
throw new TypeError('A page path beginning with "/" is required.');
|
|
158
|
+
}
|
|
159
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
160
|
+
throw new TypeError('Page options must be an object.');
|
|
161
|
+
}
|
|
162
|
+
const { template, css, shared, scope = shared ? 'shared' : 'connection', live = true, layout } = options;
|
|
163
|
+
if (template !== undefined && (typeof template !== 'string' || !template)) {
|
|
164
|
+
throw new TypeError('Page template must be a non-empty path.');
|
|
165
|
+
}
|
|
166
|
+
const stylesheets = css === undefined ? undefined : [...new Set(Array.isArray(css) ? css : [css])];
|
|
167
|
+
if (stylesheets && (stylesheets.length === 0 || stylesheets.some(file => typeof file !== 'string' || !file))) {
|
|
168
|
+
throw new TypeError('Page css must be a non-empty path or array of non-empty paths.');
|
|
169
|
+
}
|
|
170
|
+
if (shared !== undefined && typeof shared !== 'boolean') {
|
|
171
|
+
throw new TypeError('Page shared must be a boolean.');
|
|
172
|
+
}
|
|
173
|
+
if (shared !== undefined && options.scope !== undefined && (scope === 'shared') !== shared) {
|
|
174
|
+
throw new TypeError('Page scope and shared options conflict.');
|
|
175
|
+
}
|
|
176
|
+
if (!['connection', 'shared'].includes(scope)) {
|
|
177
|
+
throw new TypeError('Page scope must be "connection" or "shared".');
|
|
178
|
+
}
|
|
179
|
+
if (typeof live !== 'boolean') throw new TypeError('Page live must be a boolean.');
|
|
180
|
+
if (layout !== undefined && typeof layout !== 'function') throw new TypeError('Page layout must be a function.');
|
|
181
|
+
const head = pageHead(options.head);
|
|
182
|
+
const cache = pageCache(options.cache, live);
|
|
183
|
+
return PageClass => {
|
|
184
|
+
if (typeof PageClass !== 'function') throw new TypeError('page() must decorate a class.');
|
|
185
|
+
PAGE_METADATA.set(PageClass, Object.freeze({
|
|
186
|
+
path: routePath,
|
|
187
|
+
template,
|
|
188
|
+
scope,
|
|
189
|
+
...(live === false && { live: false }),
|
|
190
|
+
...(head && { head }),
|
|
191
|
+
...(cache && { cache }),
|
|
192
|
+
...(stylesheets && { css: Object.freeze(stylesheets) }),
|
|
193
|
+
...(layout && { layout }),
|
|
194
|
+
}));
|
|
195
|
+
PAGE_ROOTS.set(PageClass, templateRoot);
|
|
196
|
+
return PageClass;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function component(render) {
|
|
201
|
+
if (render !== undefined) {
|
|
202
|
+
if (typeof render !== 'function') throw new TypeError('component() requires a render function.');
|
|
203
|
+
return function FunctionalComponent(properties) {
|
|
204
|
+
const result = synchronous(render(properties), 'Function components must render synchronously.');
|
|
205
|
+
const { isHtml } = require('./Html');
|
|
206
|
+
if (!isHtml(result)) throw new TypeError('Function components must return html.');
|
|
207
|
+
return result;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return ComponentClass => {
|
|
211
|
+
if (typeof ComponentClass !== 'function') throw new TypeError('component() must decorate a class.');
|
|
212
|
+
COMPONENT_CLASSES.add(ComponentClass);
|
|
213
|
+
return ComponentClass;
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isComponentClass(ComponentClass) {
|
|
218
|
+
return hierarchy(ComponentClass).some(CurrentClass => COMPONENT_CLASSES.has(CurrentClass));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function state(options = {}) {
|
|
222
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
223
|
+
throw new TypeError('State options must be an object.');
|
|
224
|
+
}
|
|
225
|
+
const { writable = false } = options;
|
|
226
|
+
if (typeof writable !== 'boolean') throw new TypeError('State writable must be a boolean.');
|
|
227
|
+
const config = Object.freeze({ writable });
|
|
228
|
+
return (target, property) => {
|
|
229
|
+
if (property?.kind === 'field') {
|
|
230
|
+
if (property.static || property.private || typeof property.name !== 'string' || !property.name) {
|
|
231
|
+
throw new TypeError('state() requires a public instance field with a string name.');
|
|
232
|
+
}
|
|
233
|
+
property.addInitializer(function registerStandardState() {
|
|
234
|
+
registerState(this.constructor, property.name, config);
|
|
235
|
+
});
|
|
236
|
+
return initialValue => initialValue;
|
|
237
|
+
}
|
|
238
|
+
const PageClass = assertDecoratorTarget(target, 'state()');
|
|
239
|
+
if (typeof property !== 'string' || !property) throw new TypeError('State property must be a non-empty string.');
|
|
240
|
+
registerState(PageClass, property, config);
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function action() {
|
|
245
|
+
return (target, method, descriptor) => {
|
|
246
|
+
if (method?.kind === 'method') {
|
|
247
|
+
if (method.static || method.private || typeof method.name !== 'string' || !method.name || typeof target !== 'function') {
|
|
248
|
+
throw new TypeError('action() requires a public instance method with a string name.');
|
|
249
|
+
}
|
|
250
|
+
method.addInitializer(function registerStandardActionInitializer() {
|
|
251
|
+
if (this[method.name] === target) registerStandardAction(this.constructor, method.name, target);
|
|
252
|
+
});
|
|
253
|
+
return target;
|
|
254
|
+
}
|
|
255
|
+
const PageClass = assertDecoratorTarget(target, 'action()');
|
|
256
|
+
if (typeof method !== 'string' || !method || typeof descriptor?.value !== 'function') {
|
|
257
|
+
throw new TypeError('action() must decorate a method.');
|
|
258
|
+
}
|
|
259
|
+
registerAction(PageClass, method, descriptor.value);
|
|
260
|
+
return descriptor;
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function view(stateName) {
|
|
265
|
+
if (typeof stateName !== 'string' || !stateName) throw new TypeError('view() requires a non-empty state name.');
|
|
266
|
+
return (target, method, descriptor) => {
|
|
267
|
+
if (method?.kind === 'method') {
|
|
268
|
+
if (method.static || method.private || typeof method.name !== 'string' || !method.name || typeof target !== 'function') {
|
|
269
|
+
throw new TypeError('view() requires a public instance method with a string name.');
|
|
270
|
+
}
|
|
271
|
+
method.addInitializer(function registerStandardViewInitializer() {
|
|
272
|
+
if (this[method.name] === target) registerView(STANDARD_VIEWS, this.constructor, stateName, method.name, target);
|
|
273
|
+
});
|
|
274
|
+
return target;
|
|
275
|
+
}
|
|
276
|
+
const PageClass = assertDecoratorTarget(target, 'view()');
|
|
277
|
+
if (typeof method !== 'string' || !method || typeof descriptor?.value !== 'function') {
|
|
278
|
+
throw new TypeError('view() must decorate a method.');
|
|
279
|
+
}
|
|
280
|
+
registerView(VIEW_METADATA, PageClass, stateName, method, descriptor.value);
|
|
281
|
+
return descriptor;
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getPageMetadata(PageClass) {
|
|
286
|
+
return PAGE_METADATA.get(PageClass);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getPageTemplateRoot(PageClass) {
|
|
290
|
+
return PAGE_ROOTS.get(PageClass);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getPageStylesheetRoots(PageClass) {
|
|
294
|
+
return PAGE_STYLESHEET_ROOTS.get(PageClass);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function setPageStylesheetRoots(PageClass, roots) {
|
|
298
|
+
PAGE_STYLESHEET_ROOTS.set(PageClass, Object.freeze([...roots]));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function getStateMetadata(PageClass) {
|
|
302
|
+
return new Map(resolvedState(PageClass));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function getActionMetadata(PageClass) {
|
|
306
|
+
return new Set(resolvedAction(PageClass).keys());
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function getStateConfig(PageClass, property) {
|
|
310
|
+
return resolvedState(PageClass).get(property);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function forEachState(PageClass, callback) {
|
|
314
|
+
resolvedState(PageClass).forEach(callback);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getActionImplementation(PageClass, method) {
|
|
318
|
+
return resolvedAction(PageClass).get(method);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function getViewImplementation(PageClass, stateName) {
|
|
322
|
+
return getViewMetadata(PageClass, stateName)?.implementation;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getViewMetadata(PageClass, stateName) {
|
|
326
|
+
return resolvedView(PageClass).get(stateName);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
action,
|
|
331
|
+
component,
|
|
332
|
+
forEachState,
|
|
333
|
+
getActionImplementation,
|
|
334
|
+
getActionMetadata,
|
|
335
|
+
getPageMetadata,
|
|
336
|
+
getPageStylesheetRoots,
|
|
337
|
+
getPageTemplateRoot,
|
|
338
|
+
getStateConfig,
|
|
339
|
+
getStateMetadata,
|
|
340
|
+
getViewImplementation,
|
|
341
|
+
getViewMetadata,
|
|
342
|
+
isComponentClass,
|
|
343
|
+
page,
|
|
344
|
+
pageCache,
|
|
345
|
+
pageHead,
|
|
346
|
+
setPageStylesheetRoots,
|
|
347
|
+
state,
|
|
348
|
+
view,
|
|
349
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { fileURLToPath } = require('url');
|
|
3
|
+
|
|
4
|
+
function filePath(fileName) {
|
|
5
|
+
return fileName.startsWith('file:') ? fileURLToPath(fileName) : fileName;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function callerDirectory(frames, internalDirectory) {
|
|
9
|
+
const caller = frames.find(frame => {
|
|
10
|
+
const fileName = frame.getFileName?.();
|
|
11
|
+
return fileName && path.dirname(filePath(fileName)) !== internalDirectory;
|
|
12
|
+
});
|
|
13
|
+
return caller ? path.dirname(filePath(caller.getFileName())) : process.cwd();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function decoratorDirectory() {
|
|
17
|
+
const prepareStackTrace = Error.prepareStackTrace;
|
|
18
|
+
try {
|
|
19
|
+
Error.prepareStackTrace = (_error, frames) => frames;
|
|
20
|
+
const error = new Error();
|
|
21
|
+
Error.captureStackTrace(error, decoratorDirectory);
|
|
22
|
+
return callerDirectory(error.stack, __dirname);
|
|
23
|
+
} finally {
|
|
24
|
+
Error.prepareStackTrace = prepareStackTrace;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { callerDirectory, decoratorDirectory, filePath };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const LiveHtmlServer = require('./LiveHtmlServer');
|
|
2
|
+
|
|
3
|
+
function start(pageOrPages, options = {}) {
|
|
4
|
+
const pages = Array.isArray(pageOrPages) ? pageOrPages : [pageOrPages];
|
|
5
|
+
if (!pages.length || pages.some(PageClass => typeof PageClass !== 'function')) {
|
|
6
|
+
throw new TypeError('start() requires a page class or a non-empty array of page classes.');
|
|
7
|
+
}
|
|
8
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
9
|
+
throw new TypeError('start() options must be an object.');
|
|
10
|
+
}
|
|
11
|
+
return new LiveHtmlServer({
|
|
12
|
+
...options,
|
|
13
|
+
pages,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { start };
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const cors = require('cors');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const HtmxRenderer = require('../htmx/HtmxRenderer'); // Import the HtmxRenderer module
|
|
6
4
|
const { validateListenerOptions } = require('../serverLifecycle');
|
|
7
5
|
|
|
8
6
|
/**
|
|
@@ -24,7 +22,6 @@ const { validateListenerOptions } = require('../serverLifecycle');
|
|
|
24
22
|
* @property {string} [ssl.cert] - Path to the SSL certificate file.
|
|
25
23
|
* @property {import('express').Application} [server] - Existing Express application to configure.
|
|
26
24
|
* @property {import('cors').CorsOptions} [corsOptions] - The CORS Options.
|
|
27
|
-
* @property {boolean} [enableHtmxRendering=false] - Enable dynamic HTMX file rendering.
|
|
28
25
|
*/
|
|
29
26
|
|
|
30
27
|
const ENCODINGS = { json: 'json', urlencoded: 'urlencoded' };
|
|
@@ -39,7 +36,6 @@ const HTTP_OPTIONS = {
|
|
|
39
36
|
ssl: null,
|
|
40
37
|
server: undefined,
|
|
41
38
|
corsOptions: undefined,
|
|
42
|
-
enableHtmxRendering: false, // New option for HTMX rendering
|
|
43
39
|
exposeErrors: false,
|
|
44
40
|
logger: console,
|
|
45
41
|
};
|
|
@@ -75,11 +71,6 @@ function assertOptions(options) {
|
|
|
75
71
|
}
|
|
76
72
|
}
|
|
77
73
|
|
|
78
|
-
function isWithin(root, candidate) {
|
|
79
|
-
const relative = path.relative(root, candidate);
|
|
80
|
-
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
74
|
/**
|
|
84
75
|
* Base HTTP Server
|
|
85
76
|
* @param {RedWebOptions} options - Configuration options for RedWeb.
|
|
@@ -113,32 +104,6 @@ function BaseHttpServer(options = {}) {
|
|
|
113
104
|
this.app.use(cors(this.options.corsOptions));
|
|
114
105
|
}
|
|
115
106
|
|
|
116
|
-
// Enable HTMX rendering if the flag is set
|
|
117
|
-
if (this.enableHtmxRendering) {
|
|
118
|
-
this.app.get('*.htmx', (req, res) => {
|
|
119
|
-
const match = this.publicPaths
|
|
120
|
-
.map(publicPath => {
|
|
121
|
-
const root = path.resolve(process.cwd(), publicPath);
|
|
122
|
-
const filePath = path.resolve(root, `.${req.path}`);
|
|
123
|
-
return { root, filePath };
|
|
124
|
-
})
|
|
125
|
-
.find(({ root, filePath }) => isWithin(root, filePath) && fs.existsSync(filePath));
|
|
126
|
-
|
|
127
|
-
if (!match) {
|
|
128
|
-
return res.status(404).send('HTMX template not found');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const renderedContent = HtmxRenderer.render(match.filePath, { rootDir: match.root });
|
|
133
|
-
res.type('html').send(renderedContent);
|
|
134
|
-
} catch (error) {
|
|
135
|
-
const message = this.exposeErrors ? `Error rendering HTMX file: ${error.message}` : 'Unable to render HTMX template';
|
|
136
|
-
res.status(500).send(message);
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
107
|
// Serve static files from public paths
|
|
143
108
|
this.publicPaths.forEach((publicPath) =>
|
|
144
109
|
this.app.use(express.static(path.resolve(process.cwd(), publicPath)))
|
|
@@ -95,6 +95,10 @@ class BaseSocketServer {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
handleUpgrade(req, sock, head) {
|
|
98
|
+
// Upgrade sockets are detached from Node's HTTP request lifecycle. A peer
|
|
99
|
+
// may reset one while admission is pending or after a rejection response;
|
|
100
|
+
// consume that transport-level event so it cannot crash the process.
|
|
101
|
+
if (typeof sock.on === 'function') sock.on('error', Function.prototype);
|
|
98
102
|
// Some websocket clients (e.g., certain UE plugins) are finicky about the
|
|
99
103
|
// HTTP upgrade path they send. Normalise the path and fall back to a default
|
|
100
104
|
// route so we can still complete the upgrade instead of tearing the socket down.
|
package/src/htmx/HtmxRenderer.js
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const vm = require('vm');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
class HtmxRenderer {
|
|
6
|
-
/**
|
|
7
|
-
* Render an .htmx file as JavaScript with embedded print statements.
|
|
8
|
-
* @param {string} filePath - Path to the .htmx file.
|
|
9
|
-
* @returns {string} Rendered HTML string with normalized whitespace.
|
|
10
|
-
*/
|
|
11
|
-
static render(filePath, { rootDir = path.dirname(path.resolve(filePath)), timeoutMs = 1000 } = {}) {
|
|
12
|
-
if (!fs.existsSync(filePath)) {
|
|
13
|
-
throw new Error(`Template file not found: ${filePath}`);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
let output = '';
|
|
17
|
-
const templateContent = fs.readFileSync(filePath, 'utf-8');
|
|
18
|
-
|
|
19
|
-
// Transform <@ ... @/> blocks into print() calls
|
|
20
|
-
const transformedTemplate = templateContent.replace(
|
|
21
|
-
/<@>([\s\S]*?)<@\/>/g,
|
|
22
|
-
(_, content) => `print(\`${content.replace(/{{\s*(.*?)\s*}}/g, '${$1}')}\`);`
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
// Wrap the script in an IIFE
|
|
26
|
-
const wrappedScript = `
|
|
27
|
-
(() => {
|
|
28
|
-
const print = (html) => output += html;
|
|
29
|
-
${transformedTemplate}
|
|
30
|
-
return output;
|
|
31
|
-
})();
|
|
32
|
-
`;
|
|
33
|
-
|
|
34
|
-
// Create a custom require function that resolves paths relative to the template
|
|
35
|
-
const resolvedRoot = path.resolve(rootDir);
|
|
36
|
-
const customRequire = (modulePath) => {
|
|
37
|
-
if (typeof modulePath !== 'string' || !modulePath.startsWith('.')) {
|
|
38
|
-
throw new Error('Templates may only require relative modules');
|
|
39
|
-
}
|
|
40
|
-
const absolutePath = path.resolve(path.dirname(filePath), modulePath);
|
|
41
|
-
const relative = path.relative(resolvedRoot, absolutePath);
|
|
42
|
-
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
43
|
-
throw new Error('Template module is outside the allowed root');
|
|
44
|
-
}
|
|
45
|
-
return require(absolutePath);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Execute the script in a sandbox
|
|
49
|
-
const script = new vm.Script(wrappedScript);
|
|
50
|
-
const sandbox = {
|
|
51
|
-
output: '',
|
|
52
|
-
require: customRequire, // Add custom require
|
|
53
|
-
__dirname: path.dirname(filePath),
|
|
54
|
-
__filename: filePath,
|
|
55
|
-
};
|
|
56
|
-
vm.createContext(sandbox);
|
|
57
|
-
|
|
58
|
-
// Get the rendered output
|
|
59
|
-
let result = script.runInContext(sandbox, { timeout: timeoutMs });
|
|
60
|
-
|
|
61
|
-
// Normalize spaces but preserve those in content
|
|
62
|
-
result = result
|
|
63
|
-
.replace(/>\s+</g, '><') // Remove spaces between tags
|
|
64
|
-
.replace(/\s+/g, ' ') // Collapse multiple spaces to one
|
|
65
|
-
.replace(/>\s+/g, '>') // Remove spaces after tags
|
|
66
|
-
.replace(/\s+</g, '<') // Remove spaces before tags
|
|
67
|
-
.trim(); // Trim leading and trailing spaces
|
|
68
|
-
|
|
69
|
-
return result;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
module.exports = HtmxRenderer;
|