@vmz/core 0.1.6 → 0.1.8

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.
@@ -437,10 +437,11 @@ export function installClientNavigation(opts = {}) {
437
437
  let localeTransitionGeneration = 0;
438
438
  /**
439
439
  * Atomic LocaleTransition (browser host slice):
440
- * validate → realize path → navigate/fetch → commit locale attrs from SSR HTML.
440
+ * - prefix: validate → realize path → navigate/fetch → commit locale attrs from SSR HTML
441
+ * - none: validate → Host persist (localStorage+cookie) → commit attrs → reload (v1; no URL rewrite)
441
442
  * Failure keeps the previous locale surface (no half-page commit).
442
443
  * @param {string} toLocale
443
- * @param {{ replace?: boolean }} [opts]
444
+ * @param {{ replace?: boolean, reload?: boolean }} [opts]
444
445
  */
445
446
  async function transitionLocale(toLocale, opts = {}) {
446
447
  const fromLocale = doc.documentElement?.getAttribute('data-locale') || null;
@@ -480,6 +481,10 @@ export function installClientNavigation(opts = {}) {
480
481
  win.__vmzLastLocaleTransition = out;
481
482
  return out;
482
483
  }
484
+ const strategy = routing.strategy || 'prefix';
485
+ if (strategy === 'none') {
486
+ return transitionLocaleNone(toLocale, fromLocale, opts);
487
+ }
483
488
  const gen = ++localeTransitionGeneration;
484
489
  const targetHref = realizePathForLocale(loc.pathname + loc.search + loc.hash, toLocale, routing);
485
490
  const result = await transitionTo(targetHref, { replace: opts.replace !== false, softFail: true });
@@ -539,6 +544,64 @@ export function installClientNavigation(opts = {}) {
539
544
  win.__vmzLastLocaleTransition = out;
540
545
  return out;
541
546
  }
547
+ /**
548
+ * `routing.strategy: 'none'` — LocaleId is Host preference, not URL.
549
+ * Persist → commit document attrs + hint → full reload so `#locales/*` re-resolve (I2 v1).
550
+ * @param {string} toLocale
551
+ * @param {string | null} fromLocale
552
+ * @param {{ reload?: boolean }} [opts]
553
+ */
554
+ function transitionLocaleNone(toLocale, fromLocale, opts = {}) {
555
+ const STORE_KEY = 'vmz.locale';
556
+ try {
557
+ try {
558
+ localStorage.setItem(STORE_KEY, toLocale);
559
+ }
560
+ catch {
561
+ /* private mode */
562
+ }
563
+ try {
564
+ doc.cookie = `${STORE_KEY}=${encodeURIComponent(toLocale)}; path=/; max-age=31536000; SameSite=Lax`;
565
+ }
566
+ catch {
567
+ /* ignore */
568
+ }
569
+ if (doc.documentElement) {
570
+ doc.documentElement.setAttribute('data-locale', toLocale);
571
+ doc.documentElement.setAttribute('lang', toLocale);
572
+ }
573
+ if (win)
574
+ win.__vmzLocaleIdHint = toLocale;
575
+ }
576
+ catch (err) {
577
+ const out = {
578
+ status: 'rolled_back',
579
+ fromLocale,
580
+ toLocale,
581
+ reason: 'persist_failed',
582
+ detail: err && err.message ? String(err.message) : String(err),
583
+ };
584
+ if (win)
585
+ win.__vmzLastLocaleTransition = out;
586
+ return out;
587
+ }
588
+ const out = {
589
+ status: 'committed',
590
+ fromLocale,
591
+ toLocale,
592
+ reason: 'ok',
593
+ strategy: 'none',
594
+ href: loc.pathname + loc.search,
595
+ reload: opts.reload !== false,
596
+ };
597
+ if (win)
598
+ win.__vmzLastLocaleTransition = out;
599
+ // Reload so generated `#locales` modules re-run __vmzLocaleId() with new preference.
600
+ if (opts.reload !== false && loc && typeof loc.reload === 'function') {
601
+ loc.reload();
602
+ }
603
+ return out;
604
+ }
542
605
  /**
543
606
  * @returns {{ strategy?: string, defaultPrefix?: string, defaultLocale?: string, locales?: string[] } | null}
544
607
  */
package/dist/dom-ssr.js CHANGED
@@ -425,8 +425,8 @@ function* streamSerializeChunks(node) {
425
425
  }
426
426
  }
427
427
  /**
428
- * Document-free rowKernel SSR: fill generator text placeholders (` `) from textSlots + item.
429
- * Prefer this over linkedom createItem was omitted because html + slots are enough.
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
430
  * @param {{ html: string, textSlots?: Record<string, number>, hostFields?: string[] }} rk
431
431
  * @param {any} item
432
432
  * @param {any} key
@@ -731,7 +731,12 @@ const serializeApi = {
731
731
  if (typeof spec.createItem === 'function') {
732
732
  dom = spec.createItem.call(inst, serializeApi, box);
733
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
+ }
734
738
  else if (spec.rowKernel && typeof spec.rowKernel.html === 'string') {
739
+ // Transitional only: pre-0.1.7 emit without serializeItem.
735
740
  dom = serializeRowFromKernel(inst, spec.rowKernel, box, k);
736
741
  }
737
742
  if (dom) {
@@ -16,7 +16,7 @@
16
16
  * Dev resolve hook propagates `?t=` onto nested relative `file:` imports under
17
17
  * dist so soft reload does not keep a stale `lib/*.js` ESM cache entry.
18
18
  */
19
- import { existsSync } from 'node:fs';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
20
  import { readdir, readFile, writeFile } from 'node:fs/promises';
21
21
  import http from 'node:http';
22
22
  import { createRequire, registerHooks } from 'node:module';
@@ -75,6 +75,8 @@ if (isDev) {
75
75
  }
76
76
  /** @type {number} */
77
77
  let reloadToken = Date.now();
78
+ /** @type {string | null} Correlatable build id from vmz dev (Living §12.8). */
79
+ let lastDevBuildId = null;
78
80
  /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
79
81
  let pageCatalog = [];
80
82
  /** @type {Map<string, any>} */
@@ -152,7 +154,7 @@ async function renderPageStream(pathname, opts = {}) {
152
154
  if (isDev && lastDevError && pageCtors.size === 0) {
153
155
  return { status: 500, stream: emitDevErrorHtml(lastDevError) };
154
156
  }
155
- const localePlan = resolveLocalePath(pathname);
157
+ const localePlan = resolveLocalePath(pathname, opts.cookieHeader);
156
158
  if (localePlan.redirectTo) {
157
159
  return { status: 302, redirect: localePlan.redirectTo, headers: { Location: localePlan.redirectTo } };
158
160
  }
@@ -477,10 +479,15 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
477
479
  `d.innerHTML="<div style='max-width:56rem;margin:0 auto'><p style='color:#f87171;font-weight:700'>Dev Error</p><pre style='white-space:pre-wrap'>"+String(e.message||e).replace(/[<>&]/g,function(c){return {"<":"&lt;",">":"&gt;","&":"&amp;"}[c]})+"</pre></div>";` +
478
480
  `document.documentElement.appendChild(d);})();</script>`
479
481
  : '';
482
+ const buildIdBoot = isDev && lastDevBuildId
483
+ ? `\n <script>window.__VMZ_DEV_BUILD_ID__=${JSON.stringify(lastDevBuildId)};</script>`
484
+ : '';
480
485
  if (signal?.aborted)
481
486
  return;
482
487
  const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
483
488
  const themeBoot = themeBootstrapScript();
489
+ const localeBoot = localeBootstrapScript();
490
+ const faviconHead = siteFaviconHeadHtml();
484
491
  const propsJson = JSON.stringify(props ?? {});
485
492
  const localeId = localeCtx.localeId || localeArtifact?.defaultLocale || 'en';
486
493
  const dir = localeCtx.dir || 'ltr';
@@ -552,9 +559,9 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
552
559
  ...(cssHref ? { cssEntry: cssHref } : {}),
553
560
  isErrorDocument: false,
554
561
  htmlExtraAttrs,
555
- headExtraHtml: themeBoot,
562
+ headExtraHtml: `${themeBoot}${localeBoot}${faviconHead}`,
556
563
  moduleScriptSrc: entrySrc,
557
- bodyTailHtml: `${live}${bootOverlay}`,
564
+ bodyTailHtml: `${live}${bootOverlay}${buildIdBoot}`,
558
565
  });
559
566
  }
560
567
  const server = http.createServer((req, res) => {
@@ -670,7 +677,7 @@ process.on('SIGINT', () => {
670
677
  * Re-import routes / pages / components with a new cache-bust token.
671
678
  * Keeps the HTTP server process alive (no Node restart).
672
679
  * Failed reloads keep the previous in-memory modules (Vite-like resilience).
673
- * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], emitted?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
680
+ * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], emitted?: string[], full?: boolean, islandHmr?: boolean, buildId?: string, sourceRevision?: string, bundleRevision?: string, changed?: string[] } }} [opts]
674
681
  */
675
682
  async function softReload(opts = {}) {
676
683
  const prevToken = reloadToken;
@@ -683,6 +690,11 @@ async function softReload(opts = {}) {
683
690
  const emitted = opts.payload?.emitted ?? [];
684
691
  const full = opts.payload?.full;
685
692
  const islandHmr = Boolean(opts.payload?.islandHmr);
693
+ const buildId = opts.payload?.buildId != null ? String(opts.payload.buildId) : null;
694
+ const sourceRevision = opts.payload?.sourceRevision != null ? String(opts.payload.sourceRevision) : null;
695
+ const bundleRevision = opts.payload?.bundleRevision != null ? String(opts.payload.bundleRevision) : null;
696
+ if (buildId)
697
+ lastDevBuildId = buildId;
686
698
  const reloadAllPages = shouldReloadAllPages({ full, affected, emitted, islandHmr });
687
699
  try {
688
700
  try {
@@ -772,13 +784,21 @@ async function softReload(opts = {}) {
772
784
  affectedChunks: affected,
773
785
  seedChunks: seeds,
774
786
  token: reloadToken,
787
+ buildId: buildId || lastDevBuildId,
788
+ sourceRevision,
789
+ bundleRevision,
790
+ serveRevision: String(reloadToken),
775
791
  full: Boolean(full),
776
792
  eventOnlyShell,
777
793
  }));
778
794
  if (!opts.quiet) {
779
795
  const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
780
796
  const scope = islandHmr ? 'island' : reloadAllPages ? 'all-pages' : `pages=${nextCtors.size}`;
781
- console.log(`vmz serve: soft reload ok (mode=${mode}; ${scope}; catalog=${pageCatalog.length}; t=${reloadToken}${aff})`);
797
+ const bid = buildId || lastDevBuildId;
798
+ const rev = bid || sourceRevision || bundleRevision
799
+ ? ` buildId=${bid || '-'} source=${sourceRevision || '-'} bundle=${bundleRevision || '-'} serve=${reloadToken}`
800
+ : ` t=${reloadToken}`;
801
+ console.log(`vmz serve: soft reload ok (mode=${mode}; ${scope}; catalog=${pageCatalog.length};${rev}${aff})`);
782
802
  }
783
803
  return {
784
804
  affectedChunks: affected,
@@ -790,6 +810,11 @@ async function softReload(opts = {}) {
790
810
  pageCount: pageCatalog.length,
791
811
  reloadedPages: islandHmr ? 0 : nextCtors.size,
792
812
  reloadAllPages,
813
+ buildId: buildId || lastDevBuildId,
814
+ sourceRevision,
815
+ bundleRevision,
816
+ serveRevision: String(reloadToken),
817
+ token: reloadToken,
793
818
  };
794
819
  }
795
820
  catch (err) {
@@ -899,11 +924,14 @@ function escapeHtml(s) {
899
924
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
900
925
  }
901
926
  /**
902
- * Resolve LocaleId from pathname using `_vmz/locale-route-realization.json`.
903
- * LocaleId is a realization dimension matching still uses stable route path.
927
+ * Resolve LocaleId for this request.
928
+ * - `prefix`: LocaleId from URL path (existing).
929
+ * - `none`: Host preference from cookie `vmz.locale` (validated), else defaultLocale.
930
+ * URL never carries LocaleId.
904
931
  * @param {string} pathname
932
+ * @param {string | undefined} cookieHeader
905
933
  */
906
- function resolveLocalePath(pathname) {
934
+ function resolveLocalePath(pathname, cookieHeader) {
907
935
  const raw = String(pathname || '/');
908
936
  const normalized = raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw || '/';
909
937
  if (!localeArtifact) {
@@ -913,6 +941,17 @@ function resolveLocalePath(pathname) {
913
941
  const defaultLocale = localeArtifact.defaultLocale || supported[0] || 'en';
914
942
  const directions = Object.fromEntries((localeArtifact.locales || []).map((l) => [l.id, l.direction || 'ltr']));
915
943
  const routing = localeArtifact.routing || {};
944
+ const strategy = routing.strategy || 'prefix';
945
+ if (strategy === 'none') {
946
+ const preferred = readCookie(cookieHeader, LOCALE_STORE_KEY);
947
+ const localeId = preferred && supported.includes(preferred) ? preferred : defaultLocale;
948
+ return {
949
+ localeId,
950
+ dir: directions[localeId] || 'ltr',
951
+ restPath: normalized,
952
+ redirectTo: null,
953
+ };
954
+ }
916
955
  const parts = normalized.split('/').filter(Boolean);
917
956
  let localeId = null;
918
957
  let restPath = normalized;
@@ -1442,6 +1481,8 @@ function requireNativeGenerator() {
1442
1481
  * Style Theme cookie / localStorage key (host contract, not a second theme API).
1443
1482
  */
1444
1483
  const THEME_STORE_KEY = 'vmz-theme';
1484
+ /** Host preference key for `routing.strategy: 'none'` (cookie + localStorage). */
1485
+ const LOCALE_STORE_KEY = 'vmz.locale';
1445
1486
  /**
1446
1487
  * @param {string} dir
1447
1488
  * @returns {Promise<{ cssEntry: string|null, styleTheme: typeof styleTheme, styleBundleHash: string|null }>}
@@ -1517,6 +1558,45 @@ function themeBootstrapScript() {
1517
1558
  const key = JSON.stringify(THEME_STORE_KEY);
1518
1559
  return ` <script>(function(){try{var k=${key},attr=${attr},ids=${ids};var id=localStorage.getItem(k);if(!id||ids.indexOf(id)<0)return;document.documentElement.setAttribute(attr,id);}catch(e){}})();</script>\n`;
1519
1560
  }
1561
+ /**
1562
+ * LocaleId as client state (routing.strategy = none): apply localStorage before any
1563
+ * page/client module runs so `#locales/*` pick the right variant. Prefix strategy
1564
+ * keeps LocaleId in the URL — no boot rewrite.
1565
+ * Also mirrors into cookie so the next SSR negotiate sees Host preference.
1566
+ */
1567
+ function localeBootstrapScript() {
1568
+ if (!localeArtifact)
1569
+ return '';
1570
+ const routing = localeArtifact.routing || {};
1571
+ if ((routing.strategy || 'prefix') !== 'none')
1572
+ return '';
1573
+ const ids = (localeArtifact.locales || []).map((l) => l.id).filter(Boolean);
1574
+ if (!ids.length)
1575
+ return '';
1576
+ const key = JSON.stringify(LOCALE_STORE_KEY);
1577
+ const idList = JSON.stringify(ids);
1578
+ return ` <script>(function(){try{var k=${key},ids=${idList};var id=localStorage.getItem(k);if(!id||ids.indexOf(id)<0)return;document.documentElement.setAttribute("data-locale",id);document.documentElement.setAttribute("lang",id);window.__vmzLocaleIdHint=id;document.cookie=k+"="+encodeURIComponent(id)+"; path=/; max-age=31536000; SameSite=Lax";}catch(e){}})();</script>\n`;
1579
+ }
1580
+ /**
1581
+ * Site favicon links from build artifact `_vmz/site-favicon.json` (author SVG → PNG/ICO).
1582
+ * Empty when skipped / missing — do not invent broken <link>s.
1583
+ */
1584
+ function siteFaviconHeadHtml() {
1585
+ try {
1586
+ const p = path.join(distDir, '_vmz', 'site-favicon.json');
1587
+ if (!existsSync(p))
1588
+ return '';
1589
+ // Sync read: head is per-request; file is tiny and rebuilt with dist.
1590
+ const raw = readFileSync(p, 'utf8');
1591
+ const j = JSON.parse(raw);
1592
+ if (j?.status !== 'ready' || typeof j.headHtml !== 'string')
1593
+ return '';
1594
+ return j.headHtml;
1595
+ }
1596
+ catch {
1597
+ return '';
1598
+ }
1599
+ }
1520
1600
  /**
1521
1601
  * @param {string|undefined} header
1522
1602
  * @param {string} name
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.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
6
  "exports": {