@vmz/core 0.1.5 → 0.1.7
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/dist/dom-ssr.d.ts +0 -4
- package/dist/dom-ssr.js +139 -15
- package/dist/server.js +19 -6
- package/package.json +1 -1
package/dist/dom-ssr.d.ts
CHANGED
|
@@ -2,10 +2,6 @@
|
|
|
2
2
|
* VMZ DOM SSR / hydrate / resume — precise patches, no VDOM diff.
|
|
3
3
|
* Imports client DOM primitives from ./dom-core.js for tree-shakeable browser entry.
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
6
|
-
* @param {new (props?: object) => any} Component
|
|
7
|
-
* @param {object} [props]
|
|
8
|
-
*/
|
|
9
5
|
export declare function renderToString(Component: any, props?: {}, opts?: {}): Promise<any>;
|
|
10
6
|
/**
|
|
11
7
|
* Stream SSR via the same Direct serialize schedule as `renderToString`.
|
package/dist/dom-ssr.js
CHANGED
|
@@ -4,40 +4,90 @@
|
|
|
4
4
|
* Imports client DOM primitives from ./dom-core.js for tree-shakeable browser entry.
|
|
5
5
|
*/
|
|
6
6
|
import { applyDomAttr, applyPreservedState, BOOLEAN_HTML_ATTRS, createInstance, destroy, directApi, getRegisteredComponent, hasMeaningfulChild, isEventEntryStrategy, isEventPropName, mount, noteDomCreate, resolveComponent, runDirectCreate, scheduleClientOn, settlePendingChildMounts, snapshotInstanceState, stripFns, } from './dom-core.js';
|
|
7
|
+
/** @type {Error | null} last linkedom resolve failure (for clear SSR errors) */
|
|
8
|
+
let _ssrDocumentLastError = null;
|
|
7
9
|
/**
|
|
8
|
-
* Node SSR has no browser `document`. rowKernel
|
|
9
|
-
*
|
|
10
|
-
* Install a linkedom document once when missing (optional at runtime if linkedom is present).
|
|
10
|
+
* Node SSR has no browser `document`. rowKernel prefers document-free HTML fill;
|
|
11
|
+
* DOM hydrate is a fallback (class ternaries / missing textSlots) and needs linkedom.
|
|
11
12
|
*
|
|
12
13
|
* Must not statically import `node:module`: `dom.js` / `vmz-dom.js` re-export this file,
|
|
13
14
|
* and browser hosts load that barrel.
|
|
15
|
+
*
|
|
16
|
+
* When this file is copied into an app `dist/`, `createRequire(import.meta.url)` cannot
|
|
17
|
+
* see `@vmz/core`'s dependency tree — resolve linkedom from cwd / `@vmz/core` as well.
|
|
14
18
|
*/
|
|
15
19
|
function ensureSsrDocument() {
|
|
16
20
|
if (typeof globalThis.document !== 'undefined' && typeof globalThis.document.createElement === 'function') {
|
|
17
21
|
return true;
|
|
18
22
|
}
|
|
19
23
|
const proc = globalThis.process;
|
|
20
|
-
if (!proc?.versions?.node)
|
|
24
|
+
if (!proc?.versions?.node) {
|
|
25
|
+
_ssrDocumentLastError = new Error('vmz:dom SSR document: not running on Node');
|
|
21
26
|
return false;
|
|
27
|
+
}
|
|
22
28
|
try {
|
|
23
29
|
// Node 20.16+ / 22.3+: sync builtin load without a static `node:` import.
|
|
24
30
|
const mod = typeof proc.getBuiltinModule === 'function' ? proc.getBuiltinModule('module') : null;
|
|
25
|
-
if (!mod?.createRequire)
|
|
31
|
+
if (!mod?.createRequire) {
|
|
32
|
+
_ssrDocumentLastError = new Error('vmz:dom SSR document: createRequire unavailable');
|
|
26
33
|
return false;
|
|
27
|
-
|
|
34
|
+
}
|
|
35
|
+
const pathMod = typeof proc.getBuiltinModule === 'function' ? proc.getBuiltinModule('path') : null;
|
|
36
|
+
const createRequire = mod.createRequire;
|
|
37
|
+
/** @type {string[]} */
|
|
38
|
+
const bases = [];
|
|
39
|
+
bases.push(import.meta.url);
|
|
40
|
+
if (pathMod && typeof proc.cwd === 'function') {
|
|
41
|
+
bases.push(pathMod.join(proc.cwd(), 'package.json'));
|
|
42
|
+
}
|
|
43
|
+
/** @type {string[]} */
|
|
44
|
+
const errors = [];
|
|
45
|
+
let parseHTML = null;
|
|
46
|
+
for (const base of bases) {
|
|
47
|
+
let req;
|
|
48
|
+
try {
|
|
49
|
+
req = createRequire(base);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
errors.push(`${base}: createRequire failed: ${e && e.message ? e.message : e}`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
parseHTML = req('linkedom').parseHTML;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
errors.push(`${base} → linkedom: ${e && e.message ? e.message : e}`);
|
|
61
|
+
}
|
|
62
|
+
// Walk into @vmz/core's dependency tree (linkedom is declared there).
|
|
63
|
+
for (const coreId of ['@vmz/core', '@vmz/core/dom', '@vmz/core/server']) {
|
|
64
|
+
try {
|
|
65
|
+
const coreEntry = req.resolve(coreId);
|
|
66
|
+
parseHTML = createRequire(coreEntry)('linkedom').parseHTML;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
errors.push(`${base} → ${coreId}/linkedom: ${e && e.message ? e.message : e}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (parseHTML)
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (typeof parseHTML !== 'function') {
|
|
77
|
+
const detail = errors.length ? `\n${errors.join('\n')}` : '';
|
|
78
|
+
throw new Error(`linkedom unresolved for SSR document${detail}`);
|
|
79
|
+
}
|
|
28
80
|
const { window, document } = parseHTML('<!DOCTYPE html><html><body></body></html>');
|
|
29
81
|
globalThis.window = window;
|
|
30
82
|
globalThis.document = document;
|
|
83
|
+
_ssrDocumentLastError = null;
|
|
31
84
|
return typeof document.createElement === 'function';
|
|
32
85
|
}
|
|
33
|
-
catch {
|
|
86
|
+
catch (err) {
|
|
87
|
+
_ssrDocumentLastError = err instanceof Error ? err : new Error(String(err));
|
|
34
88
|
return false;
|
|
35
89
|
}
|
|
36
90
|
}
|
|
37
|
-
/**
|
|
38
|
-
* @param {new (props?: object) => any} Component
|
|
39
|
-
* @param {object} [props]
|
|
40
|
-
*/
|
|
41
91
|
export async function renderToString(Component, props = {}, opts = {}) {
|
|
42
92
|
ensureSsrDocument();
|
|
43
93
|
const signal = opts && opts.signal;
|
|
@@ -374,17 +424,86 @@ function* streamSerializeChunks(node) {
|
|
|
374
424
|
yield `</${tag}>`;
|
|
375
425
|
}
|
|
376
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Document-free rowKernel SSR fill (transitional / pre-0.1.7 emit only).
|
|
429
|
+
* Prefer `serializeItem` (IR schedule). Do not treat this as the long-term contract.
|
|
430
|
+
* @param {{ html: string, textSlots?: Record<string, number>, hostFields?: string[] }} rk
|
|
431
|
+
* @param {any} item
|
|
432
|
+
* @param {any} key
|
|
433
|
+
* @returns {string | null} filled outer HTML, or null if shape is not fillable
|
|
434
|
+
*/
|
|
435
|
+
function fillRowKernelHtml(rk, item, key) {
|
|
436
|
+
const slots = rk.textSlots;
|
|
437
|
+
if (!slots || typeof slots !== 'object')
|
|
438
|
+
return null;
|
|
439
|
+
/** @type {string[]} */
|
|
440
|
+
const fields = Object.entries(slots)
|
|
441
|
+
.filter(([, i]) => typeof i === 'number' && Number.isFinite(i))
|
|
442
|
+
.sort((a, b) => /** @type {number} */ (a[1]) - /** @type {number} */ (b[1]))
|
|
443
|
+
.map(([f]) => f);
|
|
444
|
+
if (!fields.length)
|
|
445
|
+
return null;
|
|
446
|
+
let slotI = 0;
|
|
447
|
+
// Generator emits one space per text interp as a dedicated text node (`> <`).
|
|
448
|
+
const filled = String(rk.html).replace(/>([^<]*)</g, (m, text) => {
|
|
449
|
+
if (text === ' ' && slotI < fields.length) {
|
|
450
|
+
const f = fields[slotI++];
|
|
451
|
+
const v = item == null ? '' : item[f];
|
|
452
|
+
return `>${escapeHtml(v == null ? '' : String(v))}<`;
|
|
453
|
+
}
|
|
454
|
+
return m;
|
|
455
|
+
});
|
|
456
|
+
if (slotI !== fields.length)
|
|
457
|
+
return null;
|
|
458
|
+
if (key == null)
|
|
459
|
+
return filled;
|
|
460
|
+
// Inject data-vmz-key on the root opening tag (same attr hydrate would set).
|
|
461
|
+
return filled.replace(/^<([A-Za-z][\w:-]*)/, `<$1 data-vmz-key="${escapeHtml(String(key))}"`);
|
|
462
|
+
}
|
|
377
463
|
/**
|
|
378
464
|
* SSR row when `createItem` was omitted (rowKernel client emit).
|
|
379
|
-
*
|
|
465
|
+
* Prefer document-free fill from `html` + `textSlots`; fall back to linkedom + hydrate
|
|
466
|
+
* when host class ternaries need live DOM, or when placeholders cannot be string-filled.
|
|
380
467
|
* @param {object} inst
|
|
381
|
-
* @param {{ html: string, hydrate?: Function }} rk
|
|
468
|
+
* @param {{ html: string, hydrate?: Function, textSlots?: Record<string, number>, hostFields?: string[] }} rk
|
|
382
469
|
* @param {{ item: any, index: number }} box
|
|
383
470
|
* @param {any} key
|
|
384
471
|
*/
|
|
385
472
|
function serializeRowFromKernel(inst, rk, box, key) {
|
|
473
|
+
const item = box.item;
|
|
474
|
+
const hostFields = Array.isArray(rk.hostFields) ? rk.hostFields : [];
|
|
475
|
+
// Text-only kernels: no document. Host class ternaries still need hydrate/DOM.
|
|
476
|
+
if (hostFields.length === 0) {
|
|
477
|
+
const raw = fillRowKernelHtml(rk, item, key);
|
|
478
|
+
if (raw != null) {
|
|
479
|
+
return {
|
|
480
|
+
__kind: 'el',
|
|
481
|
+
tag: 'div',
|
|
482
|
+
attrs: Object.create(null),
|
|
483
|
+
children: [],
|
|
484
|
+
__rawOuter: true,
|
|
485
|
+
__rawHtml: raw,
|
|
486
|
+
appendChild() { },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
386
490
|
if (!ensureSsrDocument()) {
|
|
387
|
-
|
|
491
|
+
// Degrade: still ship text fill if possible (class may be wrong until client hydrate).
|
|
492
|
+
const raw = fillRowKernelHtml(rk, item, key);
|
|
493
|
+
if (raw != null) {
|
|
494
|
+
return {
|
|
495
|
+
__kind: 'el',
|
|
496
|
+
tag: 'div',
|
|
497
|
+
attrs: Object.create(null),
|
|
498
|
+
children: [],
|
|
499
|
+
__rawOuter: true,
|
|
500
|
+
__rawHtml: raw,
|
|
501
|
+
appendChild() { },
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const cause = _ssrDocumentLastError;
|
|
505
|
+
const detail = cause && cause.message ? cause.message : 'unavailable';
|
|
506
|
+
throw new Error(`vmz:dom SSR rowKernel requires a document (createItem omitted): ${detail}`, cause ? { cause } : undefined);
|
|
388
507
|
}
|
|
389
508
|
const tpl = document.createElement('template');
|
|
390
509
|
tpl.innerHTML = rk.html;
|
|
@@ -393,7 +512,7 @@ function serializeRowFromKernel(inst, rk, box, key) {
|
|
|
393
512
|
throw new Error('vmz:dom SSR rowKernel html produced no element');
|
|
394
513
|
}
|
|
395
514
|
if (typeof rk.hydrate === 'function') {
|
|
396
|
-
rk.hydrate.call(inst, root,
|
|
515
|
+
rk.hydrate.call(inst, root, item);
|
|
397
516
|
}
|
|
398
517
|
if (key != null)
|
|
399
518
|
root.setAttribute('data-vmz-key', String(key));
|
|
@@ -612,7 +731,12 @@ const serializeApi = {
|
|
|
612
731
|
if (typeof spec.createItem === 'function') {
|
|
613
732
|
dom = spec.createItem.call(inst, serializeApi, box);
|
|
614
733
|
}
|
|
734
|
+
else if (typeof spec.serializeItem === 'function') {
|
|
735
|
+
// IR-homologous schedule (v0.1.7): same Direct body as fat createItem.
|
|
736
|
+
dom = spec.serializeItem.call(inst, serializeApi, box);
|
|
737
|
+
}
|
|
615
738
|
else if (spec.rowKernel && typeof spec.rowKernel.html === 'string') {
|
|
739
|
+
// Transitional only: pre-0.1.7 emit without serializeItem.
|
|
616
740
|
dom = serializeRowFromKernel(inst, spec.rowKernel, box, k);
|
|
617
741
|
}
|
|
618
742
|
if (dom) {
|
package/dist/server.js
CHANGED
|
@@ -690,12 +690,15 @@ function sendHtml(res, status, html) {
|
|
|
690
690
|
* @param {AbortSignal} [signal]
|
|
691
691
|
*/
|
|
692
692
|
async function sendHtmlStream(res, status, source, signal) {
|
|
693
|
-
res.
|
|
693
|
+
const aborted = () => Boolean(signal?.aborted || res.destroyed || res.writableEnded || !res.writable);
|
|
694
|
+
const headers = {
|
|
694
695
|
'content-type': 'text/html; charset=utf-8',
|
|
695
696
|
'transfer-encoding': 'chunked',
|
|
696
697
|
'cache-control': 'no-cache',
|
|
697
|
-
}
|
|
698
|
-
|
|
698
|
+
};
|
|
699
|
+
// Defer writeHead until the first successful chunk so SSR throw → clean 500
|
|
700
|
+
// (not headersSent + destroy → ERR_EMPTY_RESPONSE).
|
|
701
|
+
let started = false;
|
|
699
702
|
try {
|
|
700
703
|
for await (const chunk of source) {
|
|
701
704
|
if (aborted())
|
|
@@ -703,6 +706,10 @@ async function sendHtmlStream(res, status, source, signal) {
|
|
|
703
706
|
if (chunk == null || chunk === '')
|
|
704
707
|
continue;
|
|
705
708
|
const s = typeof chunk === 'string' ? chunk : String(chunk);
|
|
709
|
+
if (!started) {
|
|
710
|
+
res.writeHead(status, headers);
|
|
711
|
+
started = true;
|
|
712
|
+
}
|
|
706
713
|
const ok = res.write(s);
|
|
707
714
|
if (!ok) {
|
|
708
715
|
await Promise.race([
|
|
@@ -720,12 +727,18 @@ async function sendHtmlStream(res, status, source, signal) {
|
|
|
720
727
|
break;
|
|
721
728
|
}
|
|
722
729
|
}
|
|
730
|
+
if (!started && !aborted()) {
|
|
731
|
+
res.writeHead(status, headers);
|
|
732
|
+
started = true;
|
|
733
|
+
}
|
|
723
734
|
}
|
|
724
735
|
catch (err) {
|
|
725
|
-
if (
|
|
726
|
-
|
|
736
|
+
if (aborted())
|
|
737
|
+
return;
|
|
738
|
+
// Before headers: let outer handler sendJson(500). After headers: rethrow → destroy.
|
|
739
|
+
throw err;
|
|
727
740
|
}
|
|
728
|
-
if (!res.writableEnded && !res.destroyed) {
|
|
741
|
+
if (started && !res.writableEnded && !res.destroyed) {
|
|
729
742
|
res.end();
|
|
730
743
|
}
|
|
731
744
|
}
|