@trebor/buildhtml 1.0.0 → 1.0.2
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/LICENSE +21 -0
- package/README.md +699 -203
- package/index.js +1309 -201
- package/package.json +38 -37
- package/LICENSE.txt +0 -15
package/index.js
CHANGED
|
@@ -1,18 +1,67 @@
|
|
|
1
|
-
// ======================================================
|
|
2
|
-
// ULTRA-PERFORMANCE SSR BUILDER v1.0.1 (FIXED)
|
|
3
|
-
// ======================================================
|
|
4
|
-
|
|
5
1
|
'use strict';
|
|
6
2
|
|
|
7
3
|
/* ---------------- CONFIG ---------------- */
|
|
8
|
-
const CONFIG = {
|
|
4
|
+
const CONFIG = Object.freeze({
|
|
9
5
|
mode: process.env.NODE_ENV === "production" ? "prod" : "dev",
|
|
10
6
|
poolSize: 150,
|
|
11
7
|
cacheLimit: 2000,
|
|
12
|
-
maxCssCache: 1000,
|
|
13
8
|
maxKebabCache: 500,
|
|
14
|
-
compression: true
|
|
15
|
-
|
|
9
|
+
compression: true,
|
|
10
|
+
enableMetrics: process.env.ENABLE_METRICS === 'true',
|
|
11
|
+
cspNonce: process.env.CSP_NONCE_ENABLED === 'true',
|
|
12
|
+
maxComputedFnSize: 10000,
|
|
13
|
+
maxEventFnSize: 5000,
|
|
14
|
+
sanitizeCss: true
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/* ---------------- METRICS (Optional) ---------------- */
|
|
18
|
+
class Metrics {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.enabled = CONFIG.enableMetrics;
|
|
21
|
+
this.counters = new Map();
|
|
22
|
+
this.timings = new Map();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
increment(key, value = 1) {
|
|
26
|
+
if (!this.enabled) return;
|
|
27
|
+
this.counters.set(key, (this.counters.get(key) || 0) + value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
timing(key, duration) {
|
|
31
|
+
if (!this.enabled) return;
|
|
32
|
+
if (!this.timings.has(key)) this.timings.set(key, []);
|
|
33
|
+
this.timings.get(key).push(duration);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getStats() {
|
|
37
|
+
const stats = { counters: {}, timings: {} };
|
|
38
|
+
|
|
39
|
+
for (const [key, value] of this.counters) {
|
|
40
|
+
stats.counters[key] = value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const [key, values] of this.timings) {
|
|
44
|
+
const sorted = values.sort((a, b) => a - b);
|
|
45
|
+
const len = sorted.length;
|
|
46
|
+
stats.timings[key] = {
|
|
47
|
+
count: len,
|
|
48
|
+
avg: values.reduce((a, b) => a + b, 0) / len,
|
|
49
|
+
p50: sorted[Math.floor(len * 0.5)],
|
|
50
|
+
p95: sorted[Math.floor(len * 0.95)],
|
|
51
|
+
p99: sorted[Math.floor(len * 0.99)]
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return stats;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
reset() {
|
|
59
|
+
this.counters.clear();
|
|
60
|
+
this.timings.clear();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const metrics = new Metrics();
|
|
16
65
|
|
|
17
66
|
/* ---------------- OBJECT POOLS ---------------- */
|
|
18
67
|
const pools = {
|
|
@@ -25,6 +74,8 @@ function getPooled(type, ...args) {
|
|
|
25
74
|
const pool = pools[type];
|
|
26
75
|
if (pool && pool.length > 0) {
|
|
27
76
|
const item = pool.pop();
|
|
77
|
+
metrics.increment('pool.reuse.' + type);
|
|
78
|
+
|
|
28
79
|
if (type === 'elements') {
|
|
29
80
|
resetElement(item, ...args);
|
|
30
81
|
} else if (type === 'arrays') {
|
|
@@ -33,8 +84,15 @@ function getPooled(type, ...args) {
|
|
|
33
84
|
return item;
|
|
34
85
|
}
|
|
35
86
|
|
|
87
|
+
metrics.increment('pool.new.' + type);
|
|
88
|
+
|
|
36
89
|
switch (type) {
|
|
37
|
-
case 'elements':
|
|
90
|
+
case 'elements': {
|
|
91
|
+
const [tag, ridGen, stateStore, document] = args;
|
|
92
|
+
const el = new Element(tag, ridGen, stateStore);
|
|
93
|
+
el._document = document;
|
|
94
|
+
return el;
|
|
95
|
+
}
|
|
38
96
|
case 'arrays': return [];
|
|
39
97
|
case 'objects': return {};
|
|
40
98
|
default: return null;
|
|
@@ -43,54 +101,62 @@ function getPooled(type, ...args) {
|
|
|
43
101
|
|
|
44
102
|
function recycle(type, item) {
|
|
45
103
|
const pool = pools[type];
|
|
46
|
-
if (!pool || pool.length >= CONFIG.poolSize)
|
|
104
|
+
if (!pool || pool.length >= CONFIG.poolSize) {
|
|
105
|
+
metrics.increment('pool.overflow.' + type);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
47
108
|
|
|
48
109
|
if (type === 'elements' && item instanceof Element) {
|
|
49
|
-
// FIX: Recursive recycling to prevent memory leaks and orphaned children
|
|
50
110
|
for (let i = 0; i < item.children.length; i++) {
|
|
51
111
|
const child = item.children[i];
|
|
52
112
|
if (child instanceof Element) {
|
|
53
113
|
recycle('elements', child);
|
|
54
114
|
}
|
|
55
115
|
}
|
|
116
|
+
|
|
117
|
+
item.children.length = 0;
|
|
118
|
+
item.events.length = 0;
|
|
119
|
+
item._stateBindings.length = 0;
|
|
120
|
+
item.cssText = "";
|
|
121
|
+
item._state = null;
|
|
122
|
+
item._computed = null;
|
|
123
|
+
|
|
124
|
+
for (const key in item.attrs) delete item.attrs[key];
|
|
125
|
+
|
|
56
126
|
pool.push(item);
|
|
127
|
+
metrics.increment('pool.recycled.elements');
|
|
128
|
+
|
|
57
129
|
} else if (type === 'arrays' && Array.isArray(item)) {
|
|
58
130
|
item.length = 0;
|
|
59
131
|
pool.push(item);
|
|
60
|
-
|
|
61
|
-
for (const key in item) delete item[key];
|
|
62
|
-
pool.push(item);
|
|
132
|
+
metrics.increment('pool.recycled.arrays');
|
|
63
133
|
}
|
|
64
134
|
}
|
|
65
135
|
|
|
66
|
-
function resetElement(el, tag, ridGen, stateStore) {
|
|
136
|
+
function resetElement(el, tag, ridGen, stateStore, document) {
|
|
67
137
|
el.tag = toKebab(tag);
|
|
138
|
+
el._ridGen = ridGen;
|
|
139
|
+
el._stateStore = stateStore;
|
|
140
|
+
el._document = document;
|
|
68
141
|
|
|
69
|
-
|
|
70
|
-
for (const key in el.attrs) delete el.attrs[key];
|
|
71
|
-
|
|
72
|
-
// Reset arrays
|
|
142
|
+
if (!el.attrs) el.attrs = {};
|
|
73
143
|
if (!el.children) el.children = [];
|
|
74
|
-
else el.children.length = 0;
|
|
75
|
-
|
|
76
144
|
if (!el.events) el.events = [];
|
|
77
|
-
|
|
145
|
+
if (!el._stateBindings) el._stateBindings = [];
|
|
78
146
|
|
|
79
147
|
el.cssText = "";
|
|
80
148
|
el._state = null;
|
|
81
149
|
el.hydrate = false;
|
|
82
150
|
el._computed = null;
|
|
83
|
-
el._ridGen = ridGen;
|
|
84
|
-
el._stateStore = stateStore;
|
|
85
151
|
}
|
|
86
152
|
|
|
87
153
|
/* ---------------- UTILITIES ---------------- */
|
|
88
154
|
let ridCounter = 0;
|
|
89
|
-
const ridPrefix = Date.now().toString(36);
|
|
155
|
+
const ridPrefix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
90
156
|
|
|
91
157
|
const createRidGenerator = () => () => `id-${ridPrefix}${(++ridCounter).toString(36)}`;
|
|
92
158
|
|
|
93
|
-
//
|
|
159
|
+
// FNV-1a hash
|
|
94
160
|
const hash = (str) => {
|
|
95
161
|
let h = 2166136261;
|
|
96
162
|
const len = str.length;
|
|
@@ -101,36 +167,120 @@ const hash = (str) => {
|
|
|
101
167
|
return (h >>> 0).toString(36);
|
|
102
168
|
};
|
|
103
169
|
|
|
104
|
-
// Kebab-case conversion
|
|
105
|
-
|
|
170
|
+
// LRU Kebab-case conversion cache
|
|
171
|
+
class KebabCache {
|
|
172
|
+
constructor(maxSize) {
|
|
173
|
+
this.maxSize = maxSize;
|
|
174
|
+
this.cache = new Map();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get(key) {
|
|
178
|
+
if (!this.cache.has(key)) return null;
|
|
179
|
+
const value = this.cache.get(key);
|
|
180
|
+
this.cache.delete(key);
|
|
181
|
+
this.cache.set(key, value);
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
set(key, value) {
|
|
186
|
+
if (this.cache.has(key)) {
|
|
187
|
+
this.cache.delete(key);
|
|
188
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
189
|
+
const firstKey = this.cache.keys().next().value;
|
|
190
|
+
this.cache.delete(firstKey);
|
|
191
|
+
}
|
|
192
|
+
this.cache.set(key, value);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const kebabCache = new KebabCache(CONFIG.maxKebabCache);
|
|
106
197
|
const kebabRegex = /[A-Z]/g;
|
|
107
198
|
|
|
108
199
|
function toKebab(str) {
|
|
109
200
|
if (!str || typeof str !== 'string') return "";
|
|
110
201
|
|
|
111
202
|
const cached = kebabCache.get(str);
|
|
112
|
-
if (cached)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (kebabCache.size >= CONFIG.maxKebabCache) {
|
|
117
|
-
const firstKey = kebabCache.keys().next().value;
|
|
118
|
-
kebabCache.delete(firstKey);
|
|
203
|
+
if (cached) {
|
|
204
|
+
metrics.increment('kebab.cache.hit');
|
|
205
|
+
return cached;
|
|
119
206
|
}
|
|
120
207
|
|
|
208
|
+
metrics.increment('kebab.cache.miss');
|
|
209
|
+
const result = str.replace(kebabRegex, m => "-" + m.toLowerCase());
|
|
121
210
|
kebabCache.set(str, result);
|
|
122
211
|
return result;
|
|
123
212
|
}
|
|
124
213
|
|
|
125
|
-
// HTML minification
|
|
126
|
-
const minHTML = (html) =>
|
|
214
|
+
// HTML minification (Safer version to preserve inline layouts)
|
|
215
|
+
const minHTML = (html) => {
|
|
216
|
+
return html
|
|
217
|
+
// Only remove whitespace if it includes a newline or is more than 3 spaces
|
|
218
|
+
.replace(/>\s+<|>\n+</g, (m) => {
|
|
219
|
+
return m.includes('\n') || m.length > 3 ? "><" : " > <";
|
|
220
|
+
})
|
|
221
|
+
.replace(/\s{2,}/g, " ")
|
|
222
|
+
.trim();
|
|
223
|
+
};
|
|
127
224
|
|
|
128
225
|
// XSS protection
|
|
129
226
|
const escapeMap = Object.freeze({
|
|
130
|
-
'&': '&',
|
|
227
|
+
'&': '&',
|
|
228
|
+
'<': '<',
|
|
229
|
+
'>': '>',
|
|
230
|
+
'"': '"',
|
|
231
|
+
"'": ''',
|
|
232
|
+
'/': '/'
|
|
131
233
|
});
|
|
132
|
-
const escapeRegex = /[&<>"']/g;
|
|
133
|
-
const escapeHtml = (text) =>
|
|
234
|
+
const escapeRegex = /[&<>"'\/]/g;
|
|
235
|
+
const escapeHtml = (text) => {
|
|
236
|
+
if (text == null) return '';
|
|
237
|
+
return String(text).replace(escapeRegex, m => escapeMap[m]);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// HTML Entity Unescaping (For JSON transport optimization)
|
|
241
|
+
const unescapeMap = Object.freeze({
|
|
242
|
+
'&': '&',
|
|
243
|
+
'<': '<',
|
|
244
|
+
'>': '>',
|
|
245
|
+
'"': '"',
|
|
246
|
+
''': "'",
|
|
247
|
+
'/': '/'
|
|
248
|
+
});
|
|
249
|
+
const unescapeRegex = /&(?:amp|lt|gt|quot|#x27|#x2F);/g;
|
|
250
|
+
const unescapeHtml = (text) => {
|
|
251
|
+
if (text == null) return '';
|
|
252
|
+
return String(text).replace(unescapeRegex, m => unescapeMap[m]);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// CSS value sanitization
|
|
256
|
+
const cssValueRegex = /[<>"'{}()]/g;
|
|
257
|
+
const sanitizeCssValue = (value) => {
|
|
258
|
+
if (!CONFIG.sanitizeCss) return value;
|
|
259
|
+
return String(value)
|
|
260
|
+
.replace(cssValueRegex, '')
|
|
261
|
+
.replace(/\/\*/g, '')
|
|
262
|
+
.replace(/\*\//g, '')
|
|
263
|
+
.substring(0, 1000);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Validate function source
|
|
267
|
+
const sanitizeFunctionSource = (fn, maxSize) => {
|
|
268
|
+
if (typeof fn !== 'function') {
|
|
269
|
+
throw new TypeError('Expected a function');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const source = fn.toString();
|
|
273
|
+
|
|
274
|
+
if (source.length > maxSize) {
|
|
275
|
+
throw new Error(`Function source too large: ${source.length} > ${maxSize}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (source.includes('</script>') || source.includes('<script')) {
|
|
279
|
+
throw new Error('Function contains potentially malicious script tags');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return source;
|
|
283
|
+
};
|
|
134
284
|
|
|
135
285
|
/* ---------------- ELEMENT ---------------- */
|
|
136
286
|
class Element {
|
|
@@ -145,6 +295,31 @@ class Element {
|
|
|
145
295
|
this._computed = null;
|
|
146
296
|
this._ridGen = ridGen;
|
|
147
297
|
this._stateStore = stateStore;
|
|
298
|
+
this._document = null;
|
|
299
|
+
this._stateBindings = [];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
child(tag) {
|
|
303
|
+
if (!this._document) {
|
|
304
|
+
throw new Error('[Element] Cannot create child: element not associated with a document');
|
|
305
|
+
}
|
|
306
|
+
const childElement = getPooled('elements', tag, this._ridGen, this._stateStore, this._document);
|
|
307
|
+
// Automatically add to parent's children array
|
|
308
|
+
this.children.push(childElement);
|
|
309
|
+
return childElement;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
create(tag) {
|
|
313
|
+
return this.child(tag);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
attr(key, value) {
|
|
317
|
+
this.attrs[toKebab(key)] = value;
|
|
318
|
+
return this;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
attribute(key, value) {
|
|
322
|
+
return this.attr(key, value);
|
|
148
323
|
}
|
|
149
324
|
|
|
150
325
|
id(v) {
|
|
@@ -153,24 +328,97 @@ class Element {
|
|
|
153
328
|
}
|
|
154
329
|
|
|
155
330
|
text(c) {
|
|
156
|
-
|
|
331
|
+
if (c != null) {
|
|
332
|
+
this.children.push(escapeHtml(c));
|
|
333
|
+
}
|
|
157
334
|
return this;
|
|
158
335
|
}
|
|
159
336
|
|
|
337
|
+
// Allow text to be set as a property
|
|
338
|
+
set textContent(c) {
|
|
339
|
+
if (c != null) {
|
|
340
|
+
this.children = [escapeHtml(c)];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
160
344
|
append(c) {
|
|
345
|
+
if (c == null) return this;
|
|
161
346
|
this.children.push(c instanceof Element ? c : escapeHtml(c));
|
|
162
347
|
return this;
|
|
163
348
|
}
|
|
164
349
|
|
|
350
|
+
appendUnsafe(html) {
|
|
351
|
+
if (html != null) {
|
|
352
|
+
this.children.push(String(html));
|
|
353
|
+
}
|
|
354
|
+
return this;
|
|
355
|
+
}
|
|
356
|
+
|
|
165
357
|
css(s) {
|
|
166
|
-
|
|
358
|
+
if (!s || typeof s !== 'object') return this;
|
|
359
|
+
|
|
360
|
+
const cssRules = [];
|
|
167
361
|
for (const k in s) {
|
|
168
|
-
|
|
362
|
+
const key = toKebab(k);
|
|
363
|
+
const value = sanitizeCssValue(s[k]);
|
|
364
|
+
cssRules.push(`${key}:${value}`);
|
|
169
365
|
}
|
|
170
366
|
|
|
367
|
+
if (cssRules.length === 0) return this;
|
|
368
|
+
|
|
369
|
+
const cssStr = cssRules.join(';') + ';';
|
|
171
370
|
const sc = "c" + hash(cssStr);
|
|
371
|
+
|
|
172
372
|
this.attrs.class = this.attrs.class ? `${this.attrs.class} ${sc}` : sc;
|
|
173
373
|
this.cssText += `.${sc}{${cssStr}}`;
|
|
374
|
+
|
|
375
|
+
return this;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
uniqueClass(rules) {
|
|
379
|
+
if (!rules || typeof rules !== 'object') return this;
|
|
380
|
+
|
|
381
|
+
const cssRules = [];
|
|
382
|
+
for (const k in rules) {
|
|
383
|
+
const key = toKebab(k);
|
|
384
|
+
const value = sanitizeCssValue(rules[k]);
|
|
385
|
+
cssRules.push(`${key}:${value}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (cssRules.length === 0) return this;
|
|
389
|
+
|
|
390
|
+
const cssStr = cssRules.join(';') + ';';
|
|
391
|
+
const uniqueName = "_u" + Math.random().toString(36).substring(2, 9);
|
|
392
|
+
|
|
393
|
+
this.attrs.class = this.attrs.class ? `${this.attrs.class} ${uniqueName}` : uniqueName;
|
|
394
|
+
this.cssText += `.${uniqueName}{${cssStr}}`;
|
|
395
|
+
|
|
396
|
+
return this;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
bind(stateKey, templateFn = (val) => val) {
|
|
400
|
+
if (!this.attrs.id) this.id();
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const fnSource = typeof templateFn === 'function'
|
|
404
|
+
? sanitizeFunctionSource(templateFn, CONFIG.maxComputedFnSize)
|
|
405
|
+
: '(val) => val';
|
|
406
|
+
|
|
407
|
+
// Store binding info for client-side rendering
|
|
408
|
+
if (!this._stateBindings) this._stateBindings = [];
|
|
409
|
+
this._stateBindings.push({
|
|
410
|
+
stateKey,
|
|
411
|
+
id: this.attrs.id,
|
|
412
|
+
templateFn: fnSource
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
this.hydrate = true;
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (CONFIG.mode === 'dev') {
|
|
418
|
+
console.error('[Element] Invalid bind function:', err.message);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
174
422
|
return this;
|
|
175
423
|
}
|
|
176
424
|
|
|
@@ -183,24 +431,64 @@ class Element {
|
|
|
183
431
|
}
|
|
184
432
|
|
|
185
433
|
computed(fn) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
434
|
+
try {
|
|
435
|
+
sanitizeFunctionSource(fn, CONFIG.maxComputedFnSize);
|
|
436
|
+
this._computed = fn;
|
|
437
|
+
if (!this.attrs.id) this.id();
|
|
438
|
+
this.hydrate = true;
|
|
439
|
+
} catch (err) {
|
|
440
|
+
if (CONFIG.mode === 'dev') {
|
|
441
|
+
console.error('Invalid computed function:', err);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
189
444
|
return this;
|
|
190
445
|
}
|
|
191
446
|
|
|
192
447
|
on(ev, fn) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
448
|
+
try {
|
|
449
|
+
const fnSource = sanitizeFunctionSource(fn, CONFIG.maxEventFnSize);
|
|
450
|
+
|
|
451
|
+
// Warn about closures in dev mode
|
|
452
|
+
if (CONFIG.mode === 'dev') {
|
|
453
|
+
// Check for potential closure usage
|
|
454
|
+
const hasClosureRisk = /(?:let|const|var)\s+\w+/.test(fnSource) === false &&
|
|
455
|
+
/\w+\s*[\+\-\*\/\=]/.test(fnSource) &&
|
|
456
|
+
!fnSource.includes('State.');
|
|
457
|
+
|
|
458
|
+
if (hasClosureRisk) {
|
|
459
|
+
console.warn('[Sculptor] Warning: Event handler may use closures. ' +
|
|
460
|
+
'Closures won\'t work after serialization. Use State instead.');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!this.attrs.id) this.id();
|
|
465
|
+
this.events.push({ event: ev, id: this.attrs.id, fn });
|
|
466
|
+
this.hydrate = true;
|
|
467
|
+
} catch (err) {
|
|
468
|
+
if (CONFIG.mode === 'dev') {
|
|
469
|
+
console.error('Invalid event handler:', err.message);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
196
472
|
return this;
|
|
197
473
|
}
|
|
198
474
|
|
|
199
475
|
bindState(target, ev, fn) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
476
|
+
try {
|
|
477
|
+
sanitizeFunctionSource(fn, CONFIG.maxEventFnSize);
|
|
478
|
+
if (!this.attrs.id) this.id();
|
|
479
|
+
if (!target.attrs.id) target.id();
|
|
480
|
+
this.events.push({
|
|
481
|
+
event: ev,
|
|
482
|
+
id: this.attrs.id,
|
|
483
|
+
targetId: target.attrs.id,
|
|
484
|
+
fn
|
|
485
|
+
});
|
|
486
|
+
this.hydrate = true;
|
|
487
|
+
} catch (err) {
|
|
488
|
+
if (CONFIG.mode === 'dev') {
|
|
489
|
+
console.error('Invalid state binding:', err.message);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
204
492
|
return this;
|
|
205
493
|
}
|
|
206
494
|
}
|
|
@@ -215,6 +503,12 @@ class Head {
|
|
|
215
503
|
this.scripts = [];
|
|
216
504
|
this.globalStyles = [];
|
|
217
505
|
this.classStyles = {};
|
|
506
|
+
this.nonce = null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
setNonce(nonce) {
|
|
510
|
+
this.nonce = nonce;
|
|
511
|
+
return this;
|
|
218
512
|
}
|
|
219
513
|
|
|
220
514
|
setTitle(t) {
|
|
@@ -223,48 +517,75 @@ class Head {
|
|
|
223
517
|
}
|
|
224
518
|
|
|
225
519
|
addMeta(m) {
|
|
226
|
-
|
|
520
|
+
if (m && typeof m === 'object') {
|
|
521
|
+
this.metas.push(m);
|
|
522
|
+
}
|
|
227
523
|
return this;
|
|
228
524
|
}
|
|
229
525
|
|
|
230
526
|
addLink(l) {
|
|
231
|
-
if (!this.links.includes(l))
|
|
527
|
+
if (l && typeof l === 'string' && !this.links.includes(l)) {
|
|
528
|
+
this.links.push(l);
|
|
529
|
+
}
|
|
232
530
|
return this;
|
|
233
531
|
}
|
|
234
532
|
|
|
235
533
|
addStyle(s) {
|
|
236
|
-
|
|
534
|
+
if (s && typeof s === 'string') {
|
|
535
|
+
this.styles.push(s);
|
|
536
|
+
}
|
|
237
537
|
return this;
|
|
238
538
|
}
|
|
239
539
|
|
|
240
540
|
addScript(s) {
|
|
241
|
-
|
|
541
|
+
if (s && typeof s === 'string') {
|
|
542
|
+
this.scripts.push(s);
|
|
543
|
+
}
|
|
242
544
|
return this;
|
|
243
545
|
}
|
|
244
546
|
|
|
245
547
|
globalCss(selector, rules) {
|
|
246
|
-
|
|
548
|
+
if (!selector || !rules || typeof rules !== 'object') return this;
|
|
549
|
+
|
|
550
|
+
const cssRules = [];
|
|
247
551
|
for (const k in rules) {
|
|
248
|
-
|
|
552
|
+
const key = toKebab(k);
|
|
553
|
+
const value = sanitizeCssValue(rules[k]);
|
|
554
|
+
cssRules.push(`${key}:${value}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (cssRules.length > 0) {
|
|
558
|
+
const cssStr = `${selector}{${cssRules.join(';')};}`;
|
|
559
|
+
this.globalStyles.push(cssStr);
|
|
249
560
|
}
|
|
250
|
-
|
|
251
|
-
this.globalStyles.push(cssStr);
|
|
561
|
+
|
|
252
562
|
return this;
|
|
253
563
|
}
|
|
254
564
|
|
|
255
565
|
addClass(name, rules) {
|
|
256
|
-
|
|
566
|
+
if (!name || !rules || typeof rules !== 'object') return this;
|
|
567
|
+
|
|
568
|
+
const cssRules = [];
|
|
257
569
|
for (const k in rules) {
|
|
258
|
-
|
|
570
|
+
const key = toKebab(k);
|
|
571
|
+
const value = sanitizeCssValue(rules[k]);
|
|
572
|
+
cssRules.push(`${key}:${value}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (cssRules.length > 0) {
|
|
576
|
+
this.classStyles[name] = cssRules.join(';') + ';';
|
|
259
577
|
}
|
|
260
|
-
|
|
578
|
+
|
|
261
579
|
return this;
|
|
262
580
|
}
|
|
263
581
|
|
|
264
582
|
render() {
|
|
265
|
-
const parts = [
|
|
583
|
+
const parts = [];
|
|
584
|
+
const nonceAttr = this.nonce ? ` nonce="${escapeHtml(this.nonce)}"` : '';
|
|
585
|
+
|
|
586
|
+
parts.push('<meta charset="UTF-8">');
|
|
587
|
+
parts.push('<title>', this.title, '</title>');
|
|
266
588
|
|
|
267
|
-
// Meta tags
|
|
268
589
|
const metaLen = this.metas.length;
|
|
269
590
|
for (let i = 0; i < metaLen; i++) {
|
|
270
591
|
const m = this.metas[i];
|
|
@@ -275,38 +596,44 @@ class Head {
|
|
|
275
596
|
parts.push('>');
|
|
276
597
|
}
|
|
277
598
|
|
|
278
|
-
// Links
|
|
279
599
|
const linkLen = this.links.length;
|
|
280
600
|
for (let i = 0; i < linkLen; i++) {
|
|
281
601
|
parts.push('<link rel="stylesheet" href="', escapeHtml(this.links[i]), '">');
|
|
282
602
|
}
|
|
283
603
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
604
|
+
if (this.hasStyles()) {
|
|
605
|
+
parts.push('<style', nonceAttr, '>');
|
|
606
|
+
|
|
607
|
+
for (const name in this.classStyles) {
|
|
608
|
+
parts.push('.', toKebab(name), '{', this.classStyles[name], '}');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const globalLen = this.globalStyles.length;
|
|
612
|
+
for (let i = 0; i < globalLen; i++) {
|
|
613
|
+
parts.push(this.globalStyles[i]);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const styleLen = this.styles.length;
|
|
617
|
+
for (let i = 0; i < styleLen; i++) {
|
|
618
|
+
parts.push(this.styles[i]);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
parts.push('</style>');
|
|
298
622
|
}
|
|
299
623
|
|
|
300
|
-
parts.push('</style>');
|
|
301
|
-
|
|
302
|
-
// Scripts
|
|
303
624
|
const scriptLen = this.scripts.length;
|
|
304
625
|
for (let i = 0; i < scriptLen; i++) {
|
|
305
|
-
parts.push('<script src="', escapeHtml(this.scripts[i]), '"></script>');
|
|
626
|
+
parts.push('<script', nonceAttr, ' src="', escapeHtml(this.scripts[i]), '"></script>');
|
|
306
627
|
}
|
|
307
628
|
|
|
308
629
|
return parts.join('');
|
|
309
630
|
}
|
|
631
|
+
|
|
632
|
+
hasStyles() {
|
|
633
|
+
return Object.keys(this.classStyles).length > 0 ||
|
|
634
|
+
this.globalStyles.length > 0 ||
|
|
635
|
+
this.styles.length > 0;
|
|
636
|
+
}
|
|
310
637
|
}
|
|
311
638
|
|
|
312
639
|
/* ---------------- LRU CACHE ---------------- */
|
|
@@ -317,7 +644,12 @@ class LRUCache {
|
|
|
317
644
|
}
|
|
318
645
|
|
|
319
646
|
get(key) {
|
|
320
|
-
if (!this.cache.has(key))
|
|
647
|
+
if (!this.cache.has(key)) {
|
|
648
|
+
metrics.increment('cache.miss');
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
metrics.increment('cache.hit');
|
|
321
653
|
const value = this.cache.get(key);
|
|
322
654
|
this.cache.delete(key);
|
|
323
655
|
this.cache.set(key, value);
|
|
@@ -330,8 +662,10 @@ class LRUCache {
|
|
|
330
662
|
} else if (this.cache.size >= this.limit) {
|
|
331
663
|
const firstKey = this.cache.keys().next().value;
|
|
332
664
|
this.cache.delete(firstKey);
|
|
665
|
+
metrics.increment('cache.eviction');
|
|
333
666
|
}
|
|
334
667
|
this.cache.set(key, value);
|
|
668
|
+
metrics.increment('cache.set');
|
|
335
669
|
}
|
|
336
670
|
|
|
337
671
|
clear() {
|
|
@@ -339,17 +673,43 @@ class LRUCache {
|
|
|
339
673
|
}
|
|
340
674
|
|
|
341
675
|
delete(key) {
|
|
342
|
-
this.cache.delete(key);
|
|
676
|
+
return this.cache.delete(key);
|
|
343
677
|
}
|
|
344
678
|
|
|
345
679
|
has(key) {
|
|
346
680
|
return this.cache.has(key);
|
|
347
681
|
}
|
|
682
|
+
|
|
683
|
+
get size() {
|
|
684
|
+
return this.cache.size;
|
|
685
|
+
}
|
|
348
686
|
}
|
|
349
687
|
|
|
350
688
|
const responseCache = new LRUCache(CONFIG.cacheLimit);
|
|
351
689
|
const inFlightCache = new Map();
|
|
352
690
|
|
|
691
|
+
/* ---------------- ENCRYPTION ---------------- */
|
|
692
|
+
// Simple Base64 obfuscation for JSON data
|
|
693
|
+
// NOTE: This is obfuscation, NOT cryptographic security!
|
|
694
|
+
function generateEncryptionKey() {
|
|
695
|
+
// Not needed for Base64, but kept for API compatibility
|
|
696
|
+
return 1;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function encryptString(str, key) {
|
|
700
|
+
// Simple Base64 is fast and sufficient for obfuscation.
|
|
701
|
+
// Reversing large strings is too expensive for the client.
|
|
702
|
+
if (typeof Buffer !== 'undefined') {
|
|
703
|
+
return Buffer.from(str, 'utf-8').toString('base64');
|
|
704
|
+
}
|
|
705
|
+
return btoa(unescape(encodeURIComponent(str)));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function getDecryptScript() {
|
|
709
|
+
// Optimized decoder: Just decode Base64 (O(1) complexity relative to string ops)
|
|
710
|
+
return `var _d=function(e){return decodeURIComponent(escape(atob(e)));};`;
|
|
711
|
+
}
|
|
712
|
+
|
|
353
713
|
/* ---------------- DOCUMENT ---------------- */
|
|
354
714
|
class Document {
|
|
355
715
|
constructor(options = {}) {
|
|
@@ -357,8 +717,21 @@ class Document {
|
|
|
357
717
|
this.head = new Head();
|
|
358
718
|
this._ridGen = createRidGenerator();
|
|
359
719
|
this._stateStore = {};
|
|
720
|
+
this._globalState = {};
|
|
360
721
|
this._useResponseCache = options.cache ?? false;
|
|
361
722
|
this._cacheKey = options.cacheKey || null;
|
|
723
|
+
this._nonce = options.nonce || null;
|
|
724
|
+
this._oncreateCallbacks = [];
|
|
725
|
+
this._lastRendered = "";
|
|
726
|
+
|
|
727
|
+
if (this._nonce) {
|
|
728
|
+
this.head.setNonce(this._nonce);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
state(key, value) {
|
|
733
|
+
this._globalState[key] = value;
|
|
734
|
+
return this;
|
|
362
735
|
}
|
|
363
736
|
|
|
364
737
|
title(t) {
|
|
@@ -386,31 +759,107 @@ class Document {
|
|
|
386
759
|
return this;
|
|
387
760
|
}
|
|
388
761
|
|
|
389
|
-
|
|
390
|
-
this.
|
|
762
|
+
globalStyle(selector, rules) {
|
|
763
|
+
this.head.globalCss(selector, rules);
|
|
764
|
+
return this;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
sharedClass(name, rules) {
|
|
768
|
+
this.head.addClass(name, rules);
|
|
769
|
+
return this;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
defineClass(selector, rules, isRawSelector = false) {
|
|
773
|
+
if (!rules || typeof rules !== 'object') return this;
|
|
774
|
+
|
|
775
|
+
const cssRules = [];
|
|
776
|
+
for (const prop in rules) {
|
|
777
|
+
const key = toKebab(prop);
|
|
778
|
+
const val = sanitizeCssValue(rules[prop]);
|
|
779
|
+
cssRules.push(`${key}:${val}`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (cssRules.length > 0) {
|
|
783
|
+
const cssString = cssRules.join(';') + ';';
|
|
784
|
+
const finalSelector = isRawSelector ? selector : `.${selector}`;
|
|
785
|
+
|
|
786
|
+
if (isRawSelector) {
|
|
787
|
+
this.head.globalStyles.push(`${finalSelector}{${cssString}}`);
|
|
788
|
+
} else {
|
|
789
|
+
this.head.classStyles[selector] = cssString;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return this;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
oncreate(fn) {
|
|
797
|
+
if (typeof fn !== 'function') {
|
|
798
|
+
throw new Error('[Document] .oncreate() expects a function.');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
try {
|
|
802
|
+
sanitizeFunctionSource(fn, CONFIG.maxEventFnSize);
|
|
803
|
+
this._oncreateCallbacks.push(fn);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
if (CONFIG.mode === 'dev') {
|
|
806
|
+
console.error('[Document] Invalid oncreate function:', err.message);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
391
810
|
return this;
|
|
392
811
|
}
|
|
393
812
|
|
|
394
|
-
/** Add multiple elements from a function (for composition/layouts). fn(doc) returns Element or Element[]. */
|
|
395
813
|
useFragment(fn) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
814
|
+
if (typeof fn !== 'function') return this;
|
|
815
|
+
|
|
816
|
+
try {
|
|
817
|
+
const els = fn(this);
|
|
818
|
+
const arr = Array.isArray(els) ? els : [els];
|
|
819
|
+
|
|
820
|
+
for (let i = 0; i < arr.length; i++) {
|
|
821
|
+
const el = arr[i];
|
|
822
|
+
if (el != null && el instanceof Element) {
|
|
823
|
+
this.body.push(el);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
} catch (err) {
|
|
827
|
+
if (CONFIG.mode === 'dev') {
|
|
828
|
+
console.error('Fragment function error:', err);
|
|
829
|
+
}
|
|
401
830
|
}
|
|
831
|
+
|
|
402
832
|
return this;
|
|
403
833
|
}
|
|
404
834
|
|
|
405
835
|
createElement(tag) {
|
|
406
|
-
|
|
836
|
+
if (!tag || typeof tag !== 'string') {
|
|
837
|
+
throw new TypeError('Element tag must be a non-empty string');
|
|
838
|
+
}
|
|
839
|
+
const element = getPooled('elements', tag, this._ridGen, this._stateStore, this);
|
|
840
|
+
// Automatically add to document body when created from document
|
|
841
|
+
this.body.push(element);
|
|
842
|
+
return element;
|
|
407
843
|
}
|
|
408
844
|
|
|
409
|
-
/** Shorthand for createElement(tag). */
|
|
410
845
|
create(tag) {
|
|
411
846
|
return this.createElement(tag);
|
|
412
847
|
}
|
|
413
848
|
|
|
849
|
+
child(tag) {
|
|
850
|
+
return this.createElement(tag);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
output() {
|
|
854
|
+
return this._lastRendered;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
save(path) {
|
|
858
|
+
const fs = require('fs');
|
|
859
|
+
fs.writeFileSync(path, this._lastRendered);
|
|
860
|
+
return this;
|
|
861
|
+
}
|
|
862
|
+
|
|
414
863
|
clear() {
|
|
415
864
|
const bodyLen = this.body.length;
|
|
416
865
|
for (let i = 0; i < bodyLen; i++) {
|
|
@@ -419,14 +868,210 @@ class Document {
|
|
|
419
868
|
}
|
|
420
869
|
}
|
|
421
870
|
this.body.length = 0;
|
|
422
|
-
|
|
871
|
+
|
|
872
|
+
for (const key in this._stateStore) {
|
|
873
|
+
delete this._stateStore[key];
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
toJSON() {
|
|
878
|
+
const serializeElement = (el) => {
|
|
879
|
+
if (!(el instanceof Element)) {
|
|
880
|
+
// FIX: Unescape text here so client gets raw chars
|
|
881
|
+
return { type: 'text', content: unescapeHtml(String(el)) };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const serialized = {
|
|
885
|
+
type: 'element',
|
|
886
|
+
tag: el.tag,
|
|
887
|
+
attrs: { ...el.attrs },
|
|
888
|
+
children: el.children.map(serializeElement),
|
|
889
|
+
cssText: el.cssText,
|
|
890
|
+
hydrate: el.hydrate
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
if (el._state !== null) {
|
|
894
|
+
serialized.state = el._state;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (el._stateBindings && el._stateBindings.length > 0) {
|
|
898
|
+
serialized.stateBindings = el._stateBindings;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (el.events && el.events.length > 0) {
|
|
902
|
+
serialized.events = el.events.map(e => ({
|
|
903
|
+
event: e.event,
|
|
904
|
+
id: e.id,
|
|
905
|
+
targetId: e.targetId,
|
|
906
|
+
fn: e.fn.toString()
|
|
907
|
+
}));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (el._computed) {
|
|
911
|
+
serialized.computed = el._computed.toString();
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return serialized;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
version: '1.0',
|
|
919
|
+
title: this.head.title,
|
|
920
|
+
metas: this.head.metas,
|
|
921
|
+
links: this.head.links,
|
|
922
|
+
styles: this.head.styles,
|
|
923
|
+
scripts: this.head.scripts,
|
|
924
|
+
globalStyles: this.head.globalStyles,
|
|
925
|
+
classStyles: this.head.classStyles,
|
|
926
|
+
globalState: this._globalState,
|
|
927
|
+
oncreateCallbacks: this._oncreateCallbacks.map(fn => fn.toString()),
|
|
928
|
+
body: this.body.map(serializeElement)
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
static fromJSON(json) {
|
|
933
|
+
const doc = new Document();
|
|
934
|
+
|
|
935
|
+
if (typeof json === 'string') {
|
|
936
|
+
try {
|
|
937
|
+
json = JSON.parse(json);
|
|
938
|
+
} catch (e) {
|
|
939
|
+
throw new Error('[Sculptor] Invalid JSON string provided to fromJSON');
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (!json || typeof json !== 'object') {
|
|
944
|
+
throw new Error('[Sculptor] fromJSON requires a valid JSON object');
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Validate minimum required fields
|
|
948
|
+
if (!json.version) {
|
|
949
|
+
throw new Error('[Sculptor] Invalid JSON: missing version field');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (!Array.isArray(json.body)) {
|
|
953
|
+
throw new Error('[Sculptor] Invalid JSON: body must be an array');
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Restore head
|
|
957
|
+
doc.head.title = json.title || 'Document';
|
|
958
|
+
doc.head.metas = json.metas || [];
|
|
959
|
+
doc.head.links = json.links || [];
|
|
960
|
+
doc.head.styles = json.styles || [];
|
|
961
|
+
doc.head.scripts = json.scripts || [];
|
|
962
|
+
doc.head.globalStyles = json.globalStyles || [];
|
|
963
|
+
doc.head.classStyles = json.classStyles || {};
|
|
964
|
+
|
|
965
|
+
// Restore global state
|
|
966
|
+
doc._globalState = json.globalState || {};
|
|
967
|
+
|
|
968
|
+
// Restore oncreate callbacks
|
|
969
|
+
if (json.oncreateCallbacks && Array.isArray(json.oncreateCallbacks)) {
|
|
970
|
+
for (const fnStr of json.oncreateCallbacks) {
|
|
971
|
+
try {
|
|
972
|
+
// eslint-disable-next-line no-eval
|
|
973
|
+
const fn = eval(`(${fnStr})`);
|
|
974
|
+
doc._oncreateCallbacks.push(fn);
|
|
975
|
+
} catch (err) {
|
|
976
|
+
if (CONFIG.mode === 'dev') {
|
|
977
|
+
console.error('Failed to restore oncreate callback:', err);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Restore body
|
|
984
|
+
const deserializeElement = (node) => {
|
|
985
|
+
if (node.type === 'text') {
|
|
986
|
+
return node.content;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const el = getPooled('elements', node.tag, doc._ridGen, doc._stateStore, doc);
|
|
990
|
+
|
|
991
|
+
if (node.attrs) {
|
|
992
|
+
for (const key in node.attrs) {
|
|
993
|
+
el.attrs[key] = node.attrs[key];
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (node.cssText) {
|
|
998
|
+
el.cssText = node.cssText;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (node.state !== undefined) {
|
|
1002
|
+
el._state = node.state;
|
|
1003
|
+
if (el.attrs.id) {
|
|
1004
|
+
doc._stateStore[el.attrs.id] = node.state;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (node.stateBindings) {
|
|
1009
|
+
el._stateBindings = node.stateBindings;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (node.computed) {
|
|
1013
|
+
try {
|
|
1014
|
+
// eslint-disable-next-line no-eval
|
|
1015
|
+
el._computed = eval(`(${node.computed})`);
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
if (CONFIG.mode === 'dev') {
|
|
1018
|
+
console.error('Failed to restore computed function:', err);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (node.events && Array.isArray(node.events)) {
|
|
1024
|
+
for (const evt of node.events) {
|
|
1025
|
+
try {
|
|
1026
|
+
// eslint-disable-next-line no-eval
|
|
1027
|
+
const fn = eval(`(${evt.fn})`);
|
|
1028
|
+
el.events.push({
|
|
1029
|
+
event: evt.event,
|
|
1030
|
+
id: evt.id,
|
|
1031
|
+
targetId: evt.targetId,
|
|
1032
|
+
fn: fn
|
|
1033
|
+
});
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
if (CONFIG.mode === 'dev') {
|
|
1036
|
+
console.error('Failed to restore event handler:', err);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
el.hydrate = node.hydrate || false;
|
|
1043
|
+
|
|
1044
|
+
if (node.children && Array.isArray(node.children)) {
|
|
1045
|
+
for (const child of node.children) {
|
|
1046
|
+
const childEl = deserializeElement(child);
|
|
1047
|
+
el.children.push(childEl);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return el;
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
if (json.body && Array.isArray(json.body)) {
|
|
1055
|
+
for (const node of json.body) {
|
|
1056
|
+
const el = deserializeElement(node);
|
|
1057
|
+
doc.body.push(el);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return doc;
|
|
423
1062
|
}
|
|
424
1063
|
|
|
425
1064
|
render() {
|
|
1065
|
+
const startTime = CONFIG.enableMetrics ? Date.now() : 0;
|
|
1066
|
+
|
|
426
1067
|
if (this._useResponseCache && this._cacheKey) {
|
|
427
1068
|
const cached = responseCache.get(this._cacheKey);
|
|
428
1069
|
if (cached) {
|
|
429
1070
|
this.clear();
|
|
1071
|
+
if (CONFIG.enableMetrics) {
|
|
1072
|
+
metrics.timing('render.cached', Date.now() - startTime);
|
|
1073
|
+
}
|
|
1074
|
+
this._lastRendered = cached;
|
|
430
1075
|
return cached;
|
|
431
1076
|
}
|
|
432
1077
|
}
|
|
@@ -435,34 +1080,242 @@ class Document {
|
|
|
435
1080
|
events: getPooled('arrays'),
|
|
436
1081
|
states: getPooled('arrays'),
|
|
437
1082
|
styles: [],
|
|
438
|
-
computed: getPooled('arrays')
|
|
1083
|
+
computed: getPooled('arrays'),
|
|
1084
|
+
stateBindings: getPooled('arrays'),
|
|
1085
|
+
oncreates: this._oncreateCallbacks,
|
|
1086
|
+
globalState: this._globalState,
|
|
1087
|
+
nonce: this._nonce
|
|
439
1088
|
};
|
|
440
1089
|
|
|
441
1090
|
const bodyParts = [];
|
|
442
1091
|
const bodyLen = this.body.length;
|
|
443
1092
|
for (let i = 0; i < bodyLen; i++) {
|
|
444
|
-
|
|
1093
|
+
const rendered = renderNode(this.body[i], ctx);
|
|
1094
|
+
if (rendered) bodyParts.push(rendered);
|
|
445
1095
|
}
|
|
446
1096
|
|
|
447
1097
|
const bodyHTML = bodyParts.join('');
|
|
448
1098
|
const headHTML = this.head.render();
|
|
449
|
-
const stylesHTML = ctx.styles.length > 0 ?
|
|
1099
|
+
const stylesHTML = ctx.styles.length > 0 ?
|
|
1100
|
+
`<style${this._nonce ? ` nonce="${escapeHtml(this._nonce)}"` : ''}>${ctx.styles.join('')}</style>` :
|
|
1101
|
+
'';
|
|
450
1102
|
const clientJS = compileClient(ctx);
|
|
451
1103
|
|
|
452
|
-
|
|
453
|
-
|
|
1104
|
+
const html = [
|
|
1105
|
+
'<!DOCTYPE html><html lang="en"><head>',
|
|
1106
|
+
headHTML,
|
|
1107
|
+
stylesHTML,
|
|
1108
|
+
'</head><body>',
|
|
1109
|
+
bodyHTML,
|
|
1110
|
+
clientJS ? `<script${this._nonce ? ` nonce="${escapeHtml(this._nonce)}"` : ''}>${clientJS}</script>` : '',
|
|
1111
|
+
'</body></html>'
|
|
1112
|
+
].join('');
|
|
454
1113
|
|
|
455
1114
|
recycle('arrays', ctx.events);
|
|
456
1115
|
recycle('arrays', ctx.states);
|
|
457
1116
|
recycle('arrays', ctx.computed);
|
|
1117
|
+
recycle('arrays', ctx.stateBindings);
|
|
1118
|
+
|
|
1119
|
+
const result = CONFIG.mode === "prod" ? minHTML(html) : html;
|
|
1120
|
+
|
|
1121
|
+
if (this._useResponseCache && this._cacheKey) {
|
|
1122
|
+
responseCache.set(this._cacheKey, result);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
this._lastRendered = result;
|
|
1126
|
+
this.clear();
|
|
1127
|
+
|
|
1128
|
+
if (CONFIG.enableMetrics) {
|
|
1129
|
+
metrics.timing('render.total', Date.now() - startTime);
|
|
1130
|
+
metrics.increment('render.count');
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
return result;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
renderJSON(varNameOrOptions, options) {
|
|
1137
|
+
let jsonVarName = '__SCULPTOR_DATA__';
|
|
1138
|
+
let opts = {};
|
|
1139
|
+
|
|
1140
|
+
if (typeof varNameOrOptions === 'string') {
|
|
1141
|
+
jsonVarName = varNameOrOptions;
|
|
1142
|
+
opts = options || {};
|
|
1143
|
+
} else if (typeof varNameOrOptions === 'object' && varNameOrOptions !== null) {
|
|
1144
|
+
opts = varNameOrOptions;
|
|
1145
|
+
jsonVarName = opts.varName || '__SCULPTOR_DATA__';
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const startTime = CONFIG.enableMetrics ? Date.now() : 0;
|
|
1149
|
+
|
|
1150
|
+
// Check Cache
|
|
1151
|
+
if (this._useResponseCache && this._cacheKey) {
|
|
1152
|
+
const cached = responseCache.get(this._cacheKey);
|
|
1153
|
+
if (cached) {
|
|
1154
|
+
this.clear();
|
|
1155
|
+
if (CONFIG.enableMetrics) metrics.timing('render.cached', Date.now() - startTime);
|
|
1156
|
+
this._lastRendered = cached;
|
|
1157
|
+
return cached;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const jsonData = this.toJSON();
|
|
1162
|
+
const eventHandlers = {};
|
|
1163
|
+
|
|
1164
|
+
// Extract Event Handlers (Deduplication)
|
|
1165
|
+
const extractHandlers = (node) => {
|
|
1166
|
+
if (node.type === 'element') {
|
|
1167
|
+
if (node.events && Array.isArray(node.events)) {
|
|
1168
|
+
node.events = node.events.map(evt => {
|
|
1169
|
+
const handlerId = '_h' + hash(evt.fn.toString() + evt.id + evt.event);
|
|
1170
|
+
eventHandlers[handlerId] = evt.fn.toString();
|
|
1171
|
+
return {
|
|
1172
|
+
event: evt.event,
|
|
1173
|
+
id: evt.id,
|
|
1174
|
+
targetId: evt.targetId,
|
|
1175
|
+
handlerId: handlerId
|
|
1176
|
+
};
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
if (node.children) node.children.forEach(child => extractHandlers(child));
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
jsonData.body.forEach(node => extractHandlers(node));
|
|
1184
|
+
|
|
1185
|
+
const jsonStr = JSON.stringify(jsonData);
|
|
1186
|
+
const obfuscate = opts.obfuscate === true;
|
|
1187
|
+
const headHTML = this.head.render();
|
|
1188
|
+
|
|
1189
|
+
const parts = [
|
|
1190
|
+
'<!DOCTYPE html><html lang="en"><head>',
|
|
1191
|
+
headHTML,
|
|
1192
|
+
`<script${this._nonce ? ` nonce="${escapeHtml(this._nonce)}"` : ''}>`,
|
|
1193
|
+
];
|
|
1194
|
+
|
|
1195
|
+
// Inject Event Handlers (Safe Scope)
|
|
1196
|
+
if (Object.keys(eventHandlers).length > 0) {
|
|
1197
|
+
parts.push('window.__HANDLERS__={');
|
|
1198
|
+
const handlerEntries = Object.entries(eventHandlers);
|
|
1199
|
+
for (let i = 0; i < handlerEntries.length; i++) {
|
|
1200
|
+
const [id, fnStr] = handlerEntries[i];
|
|
1201
|
+
parts.push(`"${id}":${fnStr}`);
|
|
1202
|
+
if (i < handlerEntries.length - 1) parts.push(',');
|
|
1203
|
+
}
|
|
1204
|
+
parts.push('};');
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Inject JSON Data (Fast Decode)
|
|
1208
|
+
if (obfuscate) {
|
|
1209
|
+
const obfuscated = encryptString(jsonStr, true);
|
|
1210
|
+
parts.push(getDecryptScript(), `window.${jsonVarName}=JSON.parse(_d("${obfuscated}"));`);
|
|
1211
|
+
} else {
|
|
1212
|
+
parts.push(`window.${jsonVarName}=${jsonStr};`);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
parts.push('</script></head><body>');
|
|
1216
|
+
|
|
1217
|
+
// ---------------- HYDRATION ENGINE (Client Side) ---------------- //
|
|
1218
|
+
parts.push(
|
|
1219
|
+
`<script${this._nonce ? ` nonce="${escapeHtml(this._nonce)}"` : ''}>`,
|
|
1220
|
+
`(function(){`,
|
|
1221
|
+
`var data=window.${jsonVarName};`,
|
|
1222
|
+
`if(!data)return;`,
|
|
1223
|
+
|
|
1224
|
+
// HELPER: Compiler (Performance: Compiles string -> Function ONCE)
|
|
1225
|
+
`var compile=function(s){try{return new Function('return ('+s+')')();}catch(e){return function(){};}};`,
|
|
1226
|
+
|
|
1227
|
+
// 1. INJECT STYLES
|
|
1228
|
+
`var css='';`,
|
|
1229
|
+
`if(data.classStyles){for(var n in data.classStyles)css+='.'+n+'{'+data.classStyles[n]+'}';}`,
|
|
1230
|
+
`if(data.globalStyles){data.globalStyles.forEach(function(s){css+=s;});}`,
|
|
1231
|
+
`if(css){var s=document.createElement('style');s.textContent=css;document.head.appendChild(s);}`,
|
|
1232
|
+
|
|
1233
|
+
// 2. DOM BUILDER (Recursion)
|
|
1234
|
+
`var dynamicCss = '';`,
|
|
1235
|
+
`function buildEl(node){`,
|
|
1236
|
+
`if(node.type==='text') return document.createTextNode(node.content);`, // Safe: createTextNode handles escaping
|
|
1237
|
+
`var el=document.createElement(node.tag);`,
|
|
1238
|
+
`if(node.attrs){for(var k in node.attrs)el.setAttribute(k,node.attrs[k]);}`,
|
|
1239
|
+
`if(node.cssText){ dynamicCss += node.cssText; }`,
|
|
1240
|
+
`if(node.children){for(var i=0;i<node.children.length;i++){`,
|
|
1241
|
+
`el.appendChild(buildEl(node.children[i]));`,
|
|
1242
|
+
`}}`,
|
|
1243
|
+
`return el;`,
|
|
1244
|
+
`}`,
|
|
1245
|
+
|
|
1246
|
+
// 3. REACTIVE STATE PROXY
|
|
1247
|
+
`if(data.globalState){`,
|
|
1248
|
+
`var _cbs={};`,
|
|
1249
|
+
`window.watchState=function(k,f){(_cbs[k]=_cbs[k]||[]).push(f);};`,
|
|
1250
|
+
`window.State=new Proxy(data.globalState,{`,
|
|
1251
|
+
`set:function(t,k,v){if(t[k]===v)return true;t[k]=v;if(_cbs[k])_cbs[k].forEach(function(f){f(v);});return true;}`,
|
|
1252
|
+
`});}`,
|
|
1253
|
+
|
|
1254
|
+
// 4. RENDER BODY
|
|
1255
|
+
`if(data.body){`,
|
|
1256
|
+
`for(var i=0;i<data.body.length;i++){document.body.appendChild(buildEl(data.body[i]));}`,
|
|
1257
|
+
`}`,
|
|
1258
|
+
|
|
1259
|
+
// 5. INJECT DYNAMIC CSS (Buffered)
|
|
1260
|
+
`if(dynamicCss){var s=document.createElement('style');s.textContent=dynamicCss;document.head.appendChild(s);}`,
|
|
1261
|
+
|
|
1262
|
+
// 6. HYDRATION (Events & Bindings)
|
|
1263
|
+
`function hydrateNode(node){`,
|
|
1264
|
+
`if(node.type!=='element')return;`,
|
|
1265
|
+
`var el=node.attrs&&node.attrs.id?document.getElementById(node.attrs.id):null;`,
|
|
1266
|
+
`if(el){`,
|
|
1267
|
+
|
|
1268
|
+
// State Bindings (Optimized: Compile Once, Run Many)
|
|
1269
|
+
`if(node.stateBindings){`,
|
|
1270
|
+
`node.stateBindings.forEach(function(b){`,
|
|
1271
|
+
`var fn=compile(b.templateFn);`, // Compile here
|
|
1272
|
+
`window.watchState(b.stateKey,function(val){try{el.textContent=fn(val);}catch(e){}});`,
|
|
1273
|
+
`if(window.State[b.stateKey]!==undefined){try{el.textContent=fn(window.State[b.stateKey]);}catch(e){}}`,
|
|
1274
|
+
`});`,
|
|
1275
|
+
`}`,
|
|
1276
|
+
`}`,
|
|
1277
|
+
|
|
1278
|
+
// Events (Delegated via Handlers)
|
|
1279
|
+
`if(node.events){`,
|
|
1280
|
+
`node.events.forEach(function(evt){`,
|
|
1281
|
+
`var tid=evt.targetId||evt.id;`,
|
|
1282
|
+
`var te=document.getElementById(tid);`,
|
|
1283
|
+
`if(te&&evt.handlerId&&window.__HANDLERS__[evt.handlerId]){`,
|
|
1284
|
+
`try{te.addEventListener(evt.event,window.__HANDLERS__[evt.handlerId]);}catch(e){}`,
|
|
1285
|
+
`}`,
|
|
1286
|
+
`});`,
|
|
1287
|
+
`}`,
|
|
1288
|
+
|
|
1289
|
+
// Recurse Children
|
|
1290
|
+
`if(node.children){node.children.forEach(function(c){hydrateNode(c);});}`,
|
|
1291
|
+
`}`,
|
|
1292
|
+
|
|
1293
|
+
`if(data.body){data.body.forEach(function(n){hydrateNode(n);});}`,
|
|
1294
|
+
|
|
1295
|
+
// OnCreate Callbacks
|
|
1296
|
+
`if(data.oncreateCallbacks){`,
|
|
1297
|
+
`data.oncreateCallbacks.forEach(function(f){try{compile(f)();}catch(e){console.error(e);}});`,
|
|
1298
|
+
`}`,
|
|
1299
|
+
`})();`,
|
|
1300
|
+
'</script></body></html>'
|
|
1301
|
+
);
|
|
458
1302
|
|
|
1303
|
+
const html = parts.join('');
|
|
1304
|
+
// Use safer minification
|
|
459
1305
|
const result = CONFIG.mode === "prod" ? minHTML(html) : html;
|
|
460
1306
|
|
|
461
1307
|
if (this._useResponseCache && this._cacheKey) {
|
|
462
1308
|
responseCache.set(this._cacheKey, result);
|
|
463
1309
|
}
|
|
464
1310
|
|
|
1311
|
+
this._lastRendered = result;
|
|
465
1312
|
this.clear();
|
|
1313
|
+
|
|
1314
|
+
if (CONFIG.enableMetrics) {
|
|
1315
|
+
metrics.timing('render.total', Date.now() - startTime);
|
|
1316
|
+
metrics.increment('render.count');
|
|
1317
|
+
}
|
|
1318
|
+
|
|
466
1319
|
return result;
|
|
467
1320
|
}
|
|
468
1321
|
}
|
|
@@ -475,12 +1328,15 @@ const voidElements = new Set([
|
|
|
475
1328
|
|
|
476
1329
|
function renderNode(n, ctx) {
|
|
477
1330
|
if (n == null) return '';
|
|
478
|
-
if (!(n instanceof Element)) return n;
|
|
1331
|
+
if (!(n instanceof Element)) return String(n);
|
|
479
1332
|
|
|
480
1333
|
const parts = ['<', n.tag];
|
|
481
1334
|
|
|
482
1335
|
for (const k in n.attrs) {
|
|
483
|
-
|
|
1336
|
+
const value = n.attrs[k];
|
|
1337
|
+
if (value != null) {
|
|
1338
|
+
parts.push(' ', toKebab(k), '="', escapeHtml(value), '"');
|
|
1339
|
+
}
|
|
484
1340
|
}
|
|
485
1341
|
|
|
486
1342
|
parts.push('>');
|
|
@@ -492,14 +1348,26 @@ function renderNode(n, ctx) {
|
|
|
492
1348
|
}
|
|
493
1349
|
|
|
494
1350
|
if (n._computed) {
|
|
495
|
-
|
|
1351
|
+
try {
|
|
1352
|
+
const fnSource = sanitizeFunctionSource(n._computed, CONFIG.maxComputedFnSize);
|
|
1353
|
+
ctx.computed.push({ id: n.attrs.id, fn: fnSource });
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
if (CONFIG.mode === 'dev') {
|
|
1356
|
+
console.error('Computed function validation failed:', err);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Collect state bindings
|
|
1362
|
+
if (n._stateBindings && n._stateBindings.length > 0) {
|
|
1363
|
+
ctx.stateBindings.push(...n._stateBindings);
|
|
496
1364
|
}
|
|
497
1365
|
|
|
498
|
-
// FIX: Properly skip children for void elements to avoid illegal HTML
|
|
499
1366
|
if (!voidElements.has(n.tag)) {
|
|
500
1367
|
const childLen = n.children.length;
|
|
501
1368
|
for (let i = 0; i < childLen; i++) {
|
|
502
|
-
|
|
1369
|
+
const rendered = renderNode(n.children[i], ctx);
|
|
1370
|
+
if (rendered) parts.push(rendered);
|
|
503
1371
|
}
|
|
504
1372
|
parts.push('</', n.tag, '>');
|
|
505
1373
|
}
|
|
@@ -517,102 +1385,247 @@ function compileClient(ctx) {
|
|
|
517
1385
|
const hasStates = ctx.states.length > 0;
|
|
518
1386
|
const hasComputed = ctx.computed.length > 0;
|
|
519
1387
|
const hasEvents = ctx.events.length > 0;
|
|
1388
|
+
const hasOncreates = ctx.oncreates && ctx.oncreates.length > 0;
|
|
1389
|
+
const hasGlobalState = ctx.globalState && Object.keys(ctx.globalState).length > 0;
|
|
1390
|
+
const hasStateBindings = ctx.stateBindings && ctx.stateBindings.length > 0;
|
|
520
1391
|
|
|
521
|
-
if (!hasStates && !hasComputed && !hasEvents) {
|
|
1392
|
+
if (!hasStates && !hasComputed && !hasEvents && !hasOncreates && !hasGlobalState && !hasStateBindings) {
|
|
522
1393
|
return '';
|
|
523
1394
|
}
|
|
524
1395
|
|
|
525
|
-
|
|
1396
|
+
const namespace = '_ssr' + Date.now().toString(36);
|
|
1397
|
+
|
|
526
1398
|
const parts = [
|
|
527
|
-
'
|
|
528
|
-
|
|
529
|
-
'
|
|
1399
|
+
'(function(){',
|
|
1400
|
+
`var ${namespace}={state:{}};`,
|
|
1401
|
+
'var getById=function(id){return document.getElementById(id);};'
|
|
530
1402
|
];
|
|
531
1403
|
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
const valueProp = (tag) => (tag === 'input' || tag === 'textarea' ? 'value' : 'textContent');
|
|
535
|
-
for (let i = 0; i < stateLen; i++) {
|
|
536
|
-
const s = ctx.states[i];
|
|
537
|
-
const prop = valueProp(s.tag || '');
|
|
1404
|
+
// Initialize global reactive state with callbacks
|
|
1405
|
+
if (hasGlobalState || hasStateBindings) {
|
|
538
1406
|
parts.push(
|
|
539
|
-
'
|
|
540
|
-
'
|
|
1407
|
+
'var _cbs={};',
|
|
1408
|
+
'window.watchState=function(k,f){(_cbs[k]=_cbs[k]||[]).push(f);};',
|
|
1409
|
+
'window.State=new Proxy(' + JSON.stringify(ctx.globalState || {}) + ',{',
|
|
1410
|
+
'set:function(t,k,v){',
|
|
1411
|
+
'if(t[k]===v)return true;',
|
|
1412
|
+
't[k]=v;',
|
|
1413
|
+
'if(_cbs[k])_cbs[k].forEach(function(f){f(v);});',
|
|
1414
|
+
'return true;',
|
|
1415
|
+
'}',
|
|
1416
|
+
'});'
|
|
541
1417
|
);
|
|
542
1418
|
}
|
|
543
1419
|
|
|
544
|
-
|
|
1420
|
+
const stateLen = ctx.states.length;
|
|
1421
|
+
if (stateLen > 0) {
|
|
1422
|
+
parts.push('var initStates=function(){');
|
|
1423
|
+
|
|
1424
|
+
for (let i = 0; i < stateLen; i++) {
|
|
1425
|
+
const s = ctx.states[i];
|
|
1426
|
+
const prop = (s.tag === 'input' || s.tag === 'textarea') ? 'value' : 'textContent';
|
|
1427
|
+
const safeValue = JSON.stringify(s.value);
|
|
1428
|
+
|
|
1429
|
+
parts.push(
|
|
1430
|
+
`${namespace}.state["${s.id}"]=${safeValue};`,
|
|
1431
|
+
`(function(){var el=getById("${s.id}");if(el)el.${prop}=${namespace}.state["${s.id}"];})();`
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
parts.push('};');
|
|
1436
|
+
}
|
|
1437
|
+
|
|
545
1438
|
const computedLen = ctx.computed.length;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1439
|
+
if (computedLen > 0) {
|
|
1440
|
+
parts.push('var initComputed=function(){');
|
|
1441
|
+
|
|
1442
|
+
for (let i = 0; i < computedLen; i++) {
|
|
1443
|
+
const c = ctx.computed[i];
|
|
1444
|
+
parts.push(
|
|
1445
|
+
`(function(){var el=getById("${c.id}");`,
|
|
1446
|
+
`if(el)try{el.textContent=(${c.fn})(${namespace}.state);}catch(e){console.error("Computed error:",e);}`,
|
|
1447
|
+
'})();'
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
parts.push('};');
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// State bindings - use watchState
|
|
1455
|
+
if (hasStateBindings) {
|
|
1456
|
+
parts.push('var initBindings=function(){');
|
|
1457
|
+
|
|
1458
|
+
for (let i = 0; i < ctx.stateBindings.length; i++) {
|
|
1459
|
+
const b = ctx.stateBindings[i];
|
|
1460
|
+
parts.push(
|
|
1461
|
+
`window.watchState('${b.stateKey}',function(val){`,
|
|
1462
|
+
`var el=getById('${b.id}');`,
|
|
1463
|
+
`if(el)try{el.textContent=(${b.templateFn})(val);}catch(e){console.error('Binding error:',e);}`,
|
|
1464
|
+
'});'
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1467
|
+
// Initialize with current value
|
|
1468
|
+
parts.push(
|
|
1469
|
+
`(function(){`,
|
|
1470
|
+
`var el=getById('${b.id}');`,
|
|
1471
|
+
`if(el&&window.State['${b.stateKey}']!==undefined)`,
|
|
1472
|
+
`try{el.textContent=(${b.templateFn})(window.State['${b.stateKey}']);}catch(e){}`,
|
|
1473
|
+
'})();'
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
parts.push('};');
|
|
549
1478
|
}
|
|
550
1479
|
|
|
551
|
-
// Events
|
|
552
1480
|
const eventLen = ctx.events.length;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1481
|
+
if (eventLen > 0) {
|
|
1482
|
+
parts.push('var initEvents=function(){');
|
|
1483
|
+
|
|
1484
|
+
for (let i = 0; i < eventLen; i++) {
|
|
1485
|
+
const e = ctx.events[i];
|
|
1486
|
+
let fnSource = e.fn.toString();
|
|
1487
|
+
|
|
1488
|
+
if (e.targetId) {
|
|
1489
|
+
fnSource = fnSource.replace(/__STATE_ID__/g, e.targetId);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
parts.push(
|
|
1493
|
+
`(function(){var el=getById("${e.id}");`,
|
|
1494
|
+
`if(el)try{el.addEventListener("${e.event}",${fnSource});}catch(err){console.error("Event handler error:",err);}`,
|
|
1495
|
+
'})();'
|
|
1496
|
+
);
|
|
558
1497
|
}
|
|
559
|
-
|
|
1498
|
+
|
|
1499
|
+
parts.push('};');
|
|
560
1500
|
}
|
|
561
1501
|
|
|
562
|
-
|
|
1502
|
+
// Add oncreate callbacks
|
|
1503
|
+
if (hasOncreates) {
|
|
1504
|
+
parts.push('var initOncreate=function(){');
|
|
1505
|
+
|
|
1506
|
+
for (let i = 0; i < ctx.oncreates.length; i++) {
|
|
1507
|
+
const fn = ctx.oncreates[i];
|
|
1508
|
+
try {
|
|
1509
|
+
const fnSource = sanitizeFunctionSource(fn, CONFIG.maxEventFnSize);
|
|
1510
|
+
parts.push(`(${fnSource})();`);
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
if (CONFIG.mode === 'dev') {
|
|
1513
|
+
console.error('Oncreate validation failed:', err);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
parts.push('};');
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
parts.push(
|
|
1522
|
+
'if(document.readyState==="loading"){',
|
|
1523
|
+
'document.addEventListener("DOMContentLoaded",function(){'
|
|
1524
|
+
);
|
|
1525
|
+
|
|
1526
|
+
if (stateLen > 0) parts.push('initStates();');
|
|
1527
|
+
if (computedLen > 0) parts.push('initComputed();');
|
|
1528
|
+
if (hasStateBindings) parts.push('initBindings();');
|
|
1529
|
+
if (eventLen > 0) parts.push('initEvents();');
|
|
1530
|
+
if (hasOncreates) parts.push('initOncreate();');
|
|
1531
|
+
|
|
1532
|
+
parts.push(
|
|
1533
|
+
'});',
|
|
1534
|
+
'}else{'
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1537
|
+
if (stateLen > 0) parts.push('initStates();');
|
|
1538
|
+
if (computedLen > 0) parts.push('initComputed();');
|
|
1539
|
+
if (hasStateBindings) parts.push('initBindings();');
|
|
1540
|
+
if (eventLen > 0) parts.push('initEvents();');
|
|
1541
|
+
if (hasOncreates) parts.push('initOncreate();');
|
|
1542
|
+
|
|
1543
|
+
parts.push('}');
|
|
1544
|
+
parts.push(`window.${namespace}=${namespace};`);
|
|
1545
|
+
parts.push('})();');
|
|
1546
|
+
|
|
563
1547
|
return parts.join('');
|
|
564
1548
|
}
|
|
565
1549
|
|
|
566
1550
|
/* ---------------- MIDDLEWARE HELPERS ---------------- */
|
|
567
|
-
function createCachedRenderer(builderFn, cacheKeyOrFn) {
|
|
568
|
-
|
|
569
|
-
|
|
1551
|
+
function createCachedRenderer(builderFn, cacheKeyOrFn, options = {}) {
|
|
1552
|
+
if (typeof builderFn !== 'function') {
|
|
1553
|
+
throw new TypeError('Builder function is required');
|
|
1554
|
+
}
|
|
570
1555
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
1556
|
+
return async (req, res, next) => {
|
|
1557
|
+
try {
|
|
1558
|
+
const key = typeof cacheKeyOrFn === 'function' ? cacheKeyOrFn(req) : cacheKeyOrFn;
|
|
1559
|
+
|
|
1560
|
+
if (key == null || key === '') {
|
|
1561
|
+
const doc = await Promise.resolve(builderFn(req));
|
|
1562
|
+
|
|
574
1563
|
if (!doc || !(doc instanceof Document)) {
|
|
575
1564
|
return res.status(500).send('Internal Server Error');
|
|
576
1565
|
}
|
|
1566
|
+
|
|
577
1567
|
return res.send(doc.render());
|
|
578
|
-
} catch (err) {
|
|
579
|
-
return next(err);
|
|
580
1568
|
}
|
|
581
|
-
}
|
|
582
1569
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1570
|
+
const cached = responseCache.get(key);
|
|
1571
|
+
if (cached) {
|
|
1572
|
+
metrics.increment('middleware.cache.hit');
|
|
1573
|
+
return res.send(cached);
|
|
1574
|
+
}
|
|
587
1575
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
promise =
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
1576
|
+
metrics.increment('middleware.cache.miss');
|
|
1577
|
+
|
|
1578
|
+
let promise = inFlightCache.get(key);
|
|
1579
|
+
|
|
1580
|
+
if (!promise) {
|
|
1581
|
+
promise = Promise.resolve()
|
|
1582
|
+
.then(() => builderFn(req))
|
|
1583
|
+
.then((doc) => {
|
|
1584
|
+
if (!doc || !(doc instanceof Document)) {
|
|
1585
|
+
const err = new Error('Builder function must return a Document instance');
|
|
1586
|
+
err.status = 500;
|
|
1587
|
+
throw err;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
doc._useResponseCache = true;
|
|
1591
|
+
doc._cacheKey = key;
|
|
1592
|
+
|
|
1593
|
+
if (options.nonce && typeof options.nonce === 'function') {
|
|
1594
|
+
doc._nonce = options.nonce(req);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
return doc.render();
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
inFlightCache.set(key, promise);
|
|
1601
|
+
|
|
1602
|
+
promise
|
|
1603
|
+
.then((html) => {
|
|
1604
|
+
responseCache.set(key, html);
|
|
1605
|
+
metrics.increment('middleware.cache.set');
|
|
1606
|
+
})
|
|
1607
|
+
.catch((err) => {
|
|
1608
|
+
if (CONFIG.mode === 'dev') {
|
|
1609
|
+
console.error('Render error:', err);
|
|
1610
|
+
}
|
|
1611
|
+
})
|
|
1612
|
+
.finally(() => {
|
|
1613
|
+
inFlightCache.delete(key);
|
|
1614
|
+
});
|
|
1615
|
+
} else {
|
|
1616
|
+
metrics.increment('middleware.stampede.prevented');
|
|
1617
|
+
}
|
|
608
1618
|
|
|
609
|
-
|
|
1619
|
+
const html = await promise;
|
|
1620
|
+
res.send(html);
|
|
1621
|
+
|
|
1622
|
+
} catch (err) {
|
|
610
1623
|
if (err.status === 500) {
|
|
611
1624
|
res.status(500).send('Internal Server Error');
|
|
612
1625
|
} else {
|
|
613
1626
|
next(err);
|
|
614
1627
|
}
|
|
615
|
-
}
|
|
1628
|
+
}
|
|
616
1629
|
};
|
|
617
1630
|
}
|
|
618
1631
|
|
|
@@ -620,66 +1633,132 @@ function clearCache(pattern) {
|
|
|
620
1633
|
if (!pattern) {
|
|
621
1634
|
responseCache.clear();
|
|
622
1635
|
inFlightCache.clear();
|
|
1636
|
+
metrics.increment('cache.clear.all');
|
|
623
1637
|
return;
|
|
624
1638
|
}
|
|
625
1639
|
|
|
626
|
-
const keysToDelete =
|
|
1640
|
+
const keysToDelete = new Set();
|
|
1641
|
+
|
|
627
1642
|
for (const [key] of responseCache.cache) {
|
|
628
|
-
if (key.includes(pattern))
|
|
1643
|
+
if (key.includes(pattern)) {
|
|
1644
|
+
keysToDelete.add(key);
|
|
1645
|
+
}
|
|
629
1646
|
}
|
|
1647
|
+
|
|
630
1648
|
for (const key of inFlightCache.keys()) {
|
|
631
|
-
if (key.includes(pattern)
|
|
1649
|
+
if (key.includes(pattern)) {
|
|
1650
|
+
keysToDelete.add(key);
|
|
1651
|
+
}
|
|
632
1652
|
}
|
|
633
|
-
|
|
634
|
-
for (
|
|
635
|
-
responseCache.delete(
|
|
636
|
-
inFlightCache.delete(
|
|
1653
|
+
|
|
1654
|
+
for (const key of keysToDelete) {
|
|
1655
|
+
responseCache.delete(key);
|
|
1656
|
+
inFlightCache.delete(key);
|
|
637
1657
|
}
|
|
1658
|
+
|
|
1659
|
+
metrics.increment('cache.clear.pattern', keysToDelete.size);
|
|
638
1660
|
}
|
|
639
1661
|
|
|
640
|
-
// Compression middleware
|
|
641
1662
|
function enableCompression() {
|
|
642
1663
|
return (req, res, next) => {
|
|
643
1664
|
const acceptEncoding = req.headers['accept-encoding'];
|
|
644
1665
|
|
|
645
|
-
if (acceptEncoding
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1666
|
+
if (!acceptEncoding || !acceptEncoding.includes('gzip')) {
|
|
1667
|
+
return next();
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const originalSend = res.send;
|
|
1671
|
+
|
|
1672
|
+
res.send = function(data) {
|
|
1673
|
+
res.send = originalSend;
|
|
1674
|
+
|
|
1675
|
+
const contentType = this.getHeader('Content-Type') || '';
|
|
1676
|
+
const isCompressible =
|
|
1677
|
+
contentType.includes('text/html') ||
|
|
1678
|
+
contentType.includes('text/css') ||
|
|
1679
|
+
contentType.includes('text/javascript') ||
|
|
1680
|
+
contentType.includes('application/javascript') ||
|
|
1681
|
+
contentType.includes('application/json');
|
|
1682
|
+
|
|
1683
|
+
const shouldCompress =
|
|
1684
|
+
typeof data === 'string' &&
|
|
1685
|
+
data.length > 1024 &&
|
|
1686
|
+
isCompressible;
|
|
1687
|
+
|
|
1688
|
+
if (shouldCompress) {
|
|
1689
|
+
try {
|
|
1690
|
+
const zlib = require('zlib');
|
|
1691
|
+
|
|
1692
|
+
zlib.gzip(data, (err, compressed) => {
|
|
1693
|
+
if (err) {
|
|
1694
|
+
metrics.increment('compression.error');
|
|
1695
|
+
return originalSend.call(this, data);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
652
1698
|
this.setHeader('Content-Encoding', 'gzip');
|
|
653
1699
|
this.setHeader('Content-Length', compressed.length);
|
|
1700
|
+
this.setHeader('Vary', 'Accept-Encoding');
|
|
1701
|
+
metrics.increment('compression.success');
|
|
1702
|
+
metrics.increment('compression.bytes.saved', data.length - compressed.length);
|
|
1703
|
+
|
|
654
1704
|
return originalSend.call(this, compressed);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
return;
|
|
1708
|
+
|
|
1709
|
+
} catch (err) {
|
|
1710
|
+
metrics.increment('compression.error');
|
|
1711
|
+
return originalSend.call(this, data);
|
|
658
1712
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
return originalSend.call(this, data);
|
|
1716
|
+
};
|
|
662
1717
|
|
|
663
1718
|
next();
|
|
664
1719
|
};
|
|
665
1720
|
}
|
|
666
1721
|
|
|
667
|
-
|
|
668
|
-
|
|
1722
|
+
async function warmupCache(routes) {
|
|
1723
|
+
if (!Array.isArray(routes)) {
|
|
1724
|
+
throw new TypeError('Routes must be an array');
|
|
1725
|
+
}
|
|
1726
|
+
|
|
669
1727
|
const results = [];
|
|
670
1728
|
|
|
671
1729
|
for (const route of routes) {
|
|
1730
|
+
if (!route || typeof route !== 'object') {
|
|
1731
|
+
results.push({ error: 'Invalid route object', success: false });
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
672
1735
|
const { key, builder } = route;
|
|
1736
|
+
|
|
1737
|
+
if (!key || !builder) {
|
|
1738
|
+
results.push({ key, error: 'Missing key or builder', success: false });
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
673
1742
|
try {
|
|
674
|
-
const doc = builder();
|
|
1743
|
+
const doc = await Promise.resolve(builder());
|
|
1744
|
+
|
|
675
1745
|
if (!doc || !(doc instanceof Document)) {
|
|
676
1746
|
results.push({ key, error: 'Builder must return a Document instance', success: false });
|
|
677
1747
|
continue;
|
|
678
1748
|
}
|
|
1749
|
+
|
|
679
1750
|
doc._useResponseCache = true;
|
|
680
1751
|
doc._cacheKey = key;
|
|
1752
|
+
|
|
681
1753
|
const html = doc.render();
|
|
682
|
-
|
|
1754
|
+
|
|
1755
|
+
results.push({
|
|
1756
|
+
key,
|
|
1757
|
+
size: html.length,
|
|
1758
|
+
compressed: Math.floor(html.length * 0.3),
|
|
1759
|
+
success: true
|
|
1760
|
+
});
|
|
1761
|
+
|
|
683
1762
|
} catch (err) {
|
|
684
1763
|
results.push({ key, error: err.message, success: false });
|
|
685
1764
|
}
|
|
@@ -688,18 +1767,44 @@ function warmupCache(routes) {
|
|
|
688
1767
|
return results;
|
|
689
1768
|
}
|
|
690
1769
|
|
|
691
|
-
// Stats helper
|
|
692
1770
|
function getCacheStats() {
|
|
693
1771
|
return {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1772
|
+
cache: {
|
|
1773
|
+
size: responseCache.size,
|
|
1774
|
+
limit: CONFIG.cacheLimit,
|
|
1775
|
+
usage: ((responseCache.size / CONFIG.cacheLimit) * 100).toFixed(2) + '%',
|
|
1776
|
+
keys: Array.from(responseCache.cache.keys())
|
|
1777
|
+
},
|
|
1778
|
+
inFlight: {
|
|
1779
|
+
size: inFlightCache.size,
|
|
1780
|
+
keys: Array.from(inFlightCache.keys())
|
|
1781
|
+
},
|
|
1782
|
+
pools: {
|
|
699
1783
|
elements: pools.elements.length,
|
|
700
1784
|
arrays: pools.arrays.length,
|
|
701
1785
|
objects: pools.objects.length
|
|
702
|
-
}
|
|
1786
|
+
},
|
|
1787
|
+
metrics: CONFIG.enableMetrics ? metrics.getStats() : null
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function resetPools() {
|
|
1792
|
+
pools.elements.length = 0;
|
|
1793
|
+
pools.arrays.length = 0;
|
|
1794
|
+
pools.objects.length = 0;
|
|
1795
|
+
metrics.increment('pools.reset');
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function healthCheck() {
|
|
1799
|
+
return {
|
|
1800
|
+
status: 'ok',
|
|
1801
|
+
timestamp: Date.now(),
|
|
1802
|
+
config: {
|
|
1803
|
+
mode: CONFIG.mode,
|
|
1804
|
+
poolSize: CONFIG.poolSize,
|
|
1805
|
+
cacheLimit: CONFIG.cacheLimit
|
|
1806
|
+
},
|
|
1807
|
+
stats: getCacheStats()
|
|
703
1808
|
};
|
|
704
1809
|
}
|
|
705
1810
|
|
|
@@ -714,5 +1819,8 @@ module.exports = {
|
|
|
714
1819
|
enableCompression,
|
|
715
1820
|
responseCache,
|
|
716
1821
|
warmupCache,
|
|
717
|
-
getCacheStats
|
|
1822
|
+
getCacheStats,
|
|
1823
|
+
resetPools,
|
|
1824
|
+
healthCheck,
|
|
1825
|
+
metrics
|
|
718
1826
|
};
|