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,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,9 +1,7 @@
|
|
|
1
|
-
const express = require('express');
|
|
2
|
-
const path = require('path');
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
3
|
const cors = require('cors');
|
|
4
|
-
const
|
|
5
|
-
const HtmxRenderer = require('../htmx/HtmxRenderer'); // Import the HtmxRenderer module
|
|
6
|
-
const { validateListenerOptions } = require('../serverLifecycle');
|
|
4
|
+
const { validateListenerOptions } = require('../serverLifecycle');
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
7
|
* @typedef {'json' | 'urlencoded'} RedWebEncoding
|
|
@@ -15,148 +13,115 @@ const { validateListenerOptions } = require('../serverLifecycle');
|
|
|
15
13
|
* @property {number} [port=80] - The port number to bind the server.
|
|
16
14
|
* @property {string} [bind='0.0.0.0'] - The bind address for the server.
|
|
17
15
|
* @property {string[]} [publicPaths=['./public']] - An array of paths to serve static files from.
|
|
18
|
-
* @property {Array<{serviceName: string, method: string, function: Function}>} [services=[]] - An array of services with their endpoints and handlers.
|
|
19
|
-
* @property {boolean} [listen=true] - Whether HttpServer/HttpsServer should automatically start listening.
|
|
20
|
-
* @property {Function} [listenCallback] - Callback function to execute once the server starts listening.
|
|
16
|
+
* @property {Array<{serviceName: string, method: string, function: Function}>} [services=[]] - An array of services with their endpoints and handlers.
|
|
17
|
+
* @property {boolean} [listen=true] - Whether HttpServer/HttpsServer should automatically start listening.
|
|
18
|
+
* @property {Function} [listenCallback] - Callback function to execute once the server starts listening.
|
|
21
19
|
* @property {RedWebEncoding} [encoding='json'] - The encoding type for the request bodies ('json' or 'urlencoded').
|
|
22
20
|
* @property {Object} [ssl] - SSL configuration for HTTPS server.
|
|
23
21
|
* @property {string} [ssl.key] - Path to the SSL key file.
|
|
24
22
|
* @property {string} [ssl.cert] - Path to the SSL certificate file.
|
|
25
|
-
* @property {import('express').Application} [server] - Existing Express application to configure.
|
|
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' };
|
|
31
28
|
const HTTP_OPTIONS = {
|
|
32
29
|
port: 80,
|
|
33
30
|
bind: '0.0.0.0',
|
|
34
|
-
publicPaths: ['./public'],
|
|
35
|
-
services: [],
|
|
36
|
-
listen: true,
|
|
37
|
-
listenCallback: undefined,
|
|
31
|
+
publicPaths: ['./public'],
|
|
32
|
+
services: [],
|
|
33
|
+
listen: true,
|
|
34
|
+
listenCallback: undefined,
|
|
38
35
|
encoding: ENCODINGS.json,
|
|
39
36
|
ssl: null,
|
|
40
37
|
server: undefined,
|
|
41
38
|
corsOptions: undefined,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function isWithin(root, candidate) {
|
|
79
|
-
const relative = path.relative(root, candidate);
|
|
80
|
-
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
81
|
-
}
|
|
39
|
+
exposeErrors: false,
|
|
40
|
+
logger: console,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function assertOptions(options) {
|
|
44
|
+
validateListenerOptions(options);
|
|
45
|
+
if (!Object.values(ENCODINGS).includes(options.encoding)) {
|
|
46
|
+
throw new TypeError('`encoding` must be either "json" or "urlencoded".');
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(options.publicPaths)) {
|
|
49
|
+
throw new TypeError('`publicPaths` must be an array.');
|
|
50
|
+
}
|
|
51
|
+
if (options.publicPaths.some(publicPath => typeof publicPath !== 'string' || !publicPath)) {
|
|
52
|
+
throw new TypeError('Every public path must be a non-empty string.');
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(options.services)) {
|
|
55
|
+
throw new TypeError('`services` must be an array.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
options.services.forEach((service) => {
|
|
59
|
+
if (!service || typeof service.serviceName !== 'string' || !service.serviceName) {
|
|
60
|
+
throw new TypeError('Every service must have a non-empty `serviceName`.');
|
|
61
|
+
}
|
|
62
|
+
if (!['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all'].includes(service.method)) {
|
|
63
|
+
throw new TypeError(`Unsupported HTTP service method: ${service.method}`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof service.function !== 'function') {
|
|
66
|
+
throw new TypeError(`Service ${service.serviceName} must provide a function.`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
if (options.services.filter(service => service.serviceName === '*').length > 1) {
|
|
70
|
+
throw new TypeError('Only one catch-all service may be registered.');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
82
73
|
|
|
83
74
|
/**
|
|
84
75
|
* Base HTTP Server
|
|
85
76
|
* @param {RedWebOptions} options - Configuration options for RedWeb.
|
|
86
77
|
* @return {Object} Express application instance.
|
|
87
78
|
*/
|
|
88
|
-
function BaseHttpServer(options = {}) {
|
|
89
|
-
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
90
|
-
throw new TypeError('HTTP server options must be an object.');
|
|
91
|
-
}
|
|
92
|
-
const mergedOptions = { ...HTTP_OPTIONS, ...options };
|
|
93
|
-
assertOptions(mergedOptions);
|
|
94
|
-
this.options = {
|
|
95
|
-
...mergedOptions,
|
|
96
|
-
publicPaths: [...mergedOptions.publicPaths],
|
|
97
|
-
services: [...mergedOptions.services],
|
|
98
|
-
};
|
|
99
|
-
this.app = this.options.server === undefined ? express() : this.options.server;
|
|
100
|
-
if (typeof this.app.use !== 'function') {
|
|
101
|
-
throw new TypeError('`server` must be an Express-compatible application.');
|
|
102
|
-
}
|
|
103
|
-
Object.assign(this, this.options);
|
|
79
|
+
function BaseHttpServer(options = {}) {
|
|
80
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
81
|
+
throw new TypeError('HTTP server options must be an object.');
|
|
82
|
+
}
|
|
83
|
+
const mergedOptions = { ...HTTP_OPTIONS, ...options };
|
|
84
|
+
assertOptions(mergedOptions);
|
|
85
|
+
this.options = {
|
|
86
|
+
...mergedOptions,
|
|
87
|
+
publicPaths: [...mergedOptions.publicPaths],
|
|
88
|
+
services: [...mergedOptions.services],
|
|
89
|
+
};
|
|
90
|
+
this.app = this.options.server === undefined ? express() : this.options.server;
|
|
91
|
+
if (typeof this.app.use !== 'function') {
|
|
92
|
+
throw new TypeError('`server` must be an Express-compatible application.');
|
|
93
|
+
}
|
|
94
|
+
Object.assign(this, this.options);
|
|
104
95
|
|
|
105
96
|
// Middleware to parse request bodies based on the specified encoding
|
|
106
|
-
if (this.encoding === ENCODINGS.json) {
|
|
107
|
-
this.app.use(express.json());
|
|
108
|
-
} else {
|
|
109
|
-
this.app.use(express.urlencoded({ extended: true }));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (this.options.corsOptions !== false) {
|
|
113
|
-
this.app.use(cors(this.options.corsOptions));
|
|
114
|
-
}
|
|
97
|
+
if (this.encoding === ENCODINGS.json) {
|
|
98
|
+
this.app.use(express.json());
|
|
99
|
+
} else {
|
|
100
|
+
this.app.use(express.urlencoded({ extended: true }));
|
|
101
|
+
}
|
|
115
102
|
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
});
|
|
103
|
+
if (this.options.corsOptions !== false) {
|
|
104
|
+
this.app.use(cors(this.options.corsOptions));
|
|
139
105
|
}
|
|
140
|
-
|
|
141
106
|
|
|
142
|
-
// Serve static files from public paths
|
|
143
|
-
this.publicPaths.forEach((publicPath) =>
|
|
144
|
-
this.app.use(express.static(path.resolve(process.cwd(), publicPath)))
|
|
145
|
-
);
|
|
107
|
+
// Serve static files from public paths
|
|
108
|
+
this.publicPaths.forEach((publicPath) =>
|
|
109
|
+
this.app.use(express.static(path.resolve(process.cwd(), publicPath)))
|
|
110
|
+
);
|
|
146
111
|
|
|
147
|
-
const catchAll = this.services.find((service) => service.serviceName === '*');
|
|
148
|
-
this.services.filter((service) => service !== catchAll).forEach((service) =>
|
|
149
|
-
this.app[service.method](service.serviceName, service.function)
|
|
150
|
-
);
|
|
112
|
+
const catchAll = this.services.find((service) => service.serviceName === '*');
|
|
113
|
+
this.services.filter((service) => service !== catchAll).forEach((service) =>
|
|
114
|
+
this.app[service.method](service.serviceName, service.function)
|
|
115
|
+
);
|
|
151
116
|
if (catchAll) this.app[catchAll.method](catchAll.serviceName, catchAll.function);
|
|
152
117
|
|
|
153
118
|
return this;
|
|
154
119
|
}
|
|
155
120
|
|
|
156
121
|
|
|
157
|
-
module.exports = {
|
|
158
|
-
BaseHttpServer,
|
|
159
|
-
ENCODINGS,
|
|
160
|
-
HTTP_OPTIONS,
|
|
161
|
-
METHODS: { GET: 'get', POST: 'post', PUT: 'put', PATCH: 'patch', DELETE: 'delete', OPTIONS: 'options', HEAD: 'head', ALL: 'all' },
|
|
162
|
-
};
|
|
122
|
+
module.exports = {
|
|
123
|
+
BaseHttpServer,
|
|
124
|
+
ENCODINGS,
|
|
125
|
+
HTTP_OPTIONS,
|
|
126
|
+
METHODS: { GET: 'get', POST: 'post', PUT: 'put', PATCH: 'patch', DELETE: 'delete', OPTIONS: 'options', HEAD: 'head', ALL: 'all' },
|
|
127
|
+
};
|