@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 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 omits `createItem` and materializes
9
- * rows via `html` + `hydrate`, which needs `document.createElement('template')`.
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
- const { parseHTML } = mod.createRequire(import.meta.url)('linkedom');
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
- * Hydrate a detached DOM node from `rowKernel.html`, then ship outerHTML.
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
- throw new Error('vmz:dom SSR rowKernel requires a document (createItem omitted)');
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, box.item);
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.writeHead(status, {
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
- const aborted = () => Boolean(signal?.aborted || res.destroyed || res.writableEnded || !res.writable);
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 (!aborted())
726
- throw err;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
6
  "exports": {