@vmz/core 0.1.0 → 0.1.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.
@@ -237,6 +237,9 @@ export function installClientNavigation(opts = {}) {
237
237
  /** @type {Element | null} */
238
238
  let liveRoot = root;
239
239
  let retainedLayout = false;
240
+ // Apply target LocaleId before hydrate/onMount so `#locales/*` and
241
+ // retained shells (SiteHeader) see the committed projection.
242
+ applyLocaleRealization(nextApp);
240
243
  if (retainLayouts) {
241
244
  applyAppAttrs(root, nextApp);
242
245
  if (chunkId) {
@@ -670,14 +673,7 @@ function canRetainLayouts(root, prevLayout, nextLayout) {
670
673
  * @param {Element} nextApp
671
674
  */
672
675
  function applyAppAttrs(root, nextApp) {
673
- for (const name of [
674
- 'data-vmz-page',
675
- 'data-vmz-props',
676
- 'data-vmz-layout',
677
- 'data-vmz-route',
678
- 'data-vmz-locale',
679
- 'data-vmz-dir',
680
- ]) {
676
+ for (const name of ['data-vmz-page', 'data-vmz-props', 'data-vmz-layout', 'data-vmz-route', 'data-vmz-locale', 'data-vmz-dir']) {
681
677
  const v = nextApp.getAttribute(name);
682
678
  if (v == null)
683
679
  root.removeAttribute(name);
package/dist/dom-core.js CHANGED
@@ -1158,12 +1158,8 @@ export const directApi = {
1158
1158
  // DOM-as-entry (Element): drop expandos.
1159
1159
  if (entry.nodeType === 1) {
1160
1160
  entry.__vmzBox = null;
1161
- entry.__vmzT0 = null;
1162
- entry.__vmzT1 = null;
1163
- entry.__vmzE0 = null;
1164
- entry.__vmzE1 = null;
1165
- entry.__vmzE2 = null;
1166
- entry.__vmzE3 = null;
1161
+ entry.__vmzT = null;
1162
+ entry.__vmzE = null;
1167
1163
  entry.__vmzTexts = null;
1168
1164
  entry.__vmzBp = null;
1169
1165
  return;
@@ -2345,45 +2341,19 @@ export const directApi = {
2345
2341
  return false;
2346
2342
  const entries = entryByIndex;
2347
2343
  const n = arr.length;
2348
- // Fastest path: text-only leaf → mutate + __vmzT{n}.nodeValue (no applyByField).
2344
+ // Fastest path: text-only leaf → mutate + __vmzT[slot].nodeValue (no applyByField).
2349
2345
  const slot = rkTextSlots ? rkTextSlots[leaf] : undefined;
2350
2346
  if (op === '+' && slot != null && !rkHostFieldSet.has(leaf)) {
2351
- if (slot === 0) {
2352
- for (let i = s; i < n; i += st) {
2353
- const item = arr[i];
2354
- if (item == null || typeof item !== 'object')
2355
- continue;
2356
- const v = item[leaf] + rhs;
2357
- item[leaf] = v;
2358
- const entry = entries[i];
2359
- if (entry && entry.nodeType === 1)
2360
- entry.__vmzT0.nodeValue = v;
2361
- }
2362
- }
2363
- else if (slot === 1) {
2364
- for (let i = s; i < n; i += st) {
2365
- const item = arr[i];
2366
- if (item == null || typeof item !== 'object')
2367
- continue;
2368
- const v = item[leaf] + rhs;
2369
- item[leaf] = v;
2370
- const entry = entries[i];
2371
- if (entry && entry.nodeType === 1)
2372
- entry.__vmzT1.nodeValue = v;
2373
- }
2374
- }
2375
- else {
2376
- const tKey = '__vmzT' + slot;
2377
- for (let i = s; i < n; i += st) {
2378
- const item = arr[i];
2379
- if (item == null || typeof item !== 'object')
2380
- continue;
2381
- const v = item[leaf] + rhs;
2382
- item[leaf] = v;
2383
- const entry = entries[i];
2384
- if (entry && entry.nodeType === 1)
2385
- entry[tKey].nodeValue = v;
2386
- }
2347
+ for (let i = s; i < n; i += st) {
2348
+ const item = arr[i];
2349
+ if (item == null || typeof item !== 'object')
2350
+ continue;
2351
+ const v = item[leaf] + rhs;
2352
+ item[leaf] = v;
2353
+ const entry = entries[i];
2354
+ const texts = entry && entry.nodeType === 1 ? entry.__vmzT : null;
2355
+ if (texts)
2356
+ texts[slot].nodeValue = v;
2387
2357
  }
2388
2358
  return true;
2389
2359
  }
@@ -2432,20 +2402,10 @@ export const directApi = {
2432
2402
  item[leaf] = value;
2433
2403
  if (slot != null && !needsThis) {
2434
2404
  const entry = entries[i];
2435
- if (entry && entry.nodeType === 1) {
2436
- if (slot === 0) {
2437
- entry.__vmzT0.nodeValue = value;
2438
- continue;
2439
- }
2440
- if (slot === 1) {
2441
- entry.__vmzT1.nodeValue = value;
2442
- continue;
2443
- }
2444
- const tn = entry['__vmzT' + slot];
2445
- if (tn) {
2446
- tn.nodeValue = value;
2447
- continue;
2448
- }
2405
+ const texts = entry && entry.nodeType === 1 ? entry.__vmzT : null;
2406
+ if (texts) {
2407
+ texts[slot].nodeValue = value;
2408
+ continue;
2449
2409
  }
2450
2410
  }
2451
2411
  const entry = entries[i];
@@ -2692,6 +2652,26 @@ export function applyDomAttr(el, name, value) {
2692
2652
  }
2693
2653
  return;
2694
2654
  }
2655
+ // <textarea value="…"> as an attribute does not update visible text; INPUT/SELECT
2656
+ // also need the IDL `.value` property so controlled updates stay in sync after switches.
2657
+ // linkedom `<select>.value` is getter-only — sync via `option.selected` instead of throwing.
2658
+ if (key === 'value' && el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' || el.tagName === 'SELECT')) {
2659
+ const next = value == null || value === false ? '' : String(value);
2660
+ if (el.tagName === 'SELECT') {
2661
+ const opts = el.options || el.querySelectorAll?.('option') || [];
2662
+ for (const opt of opts) {
2663
+ opt.selected = String(opt.value ?? '') === next;
2664
+ }
2665
+ }
2666
+ else if (el.value !== next) {
2667
+ el.value = next;
2668
+ }
2669
+ if (value == null || value === false)
2670
+ el.removeAttribute('value');
2671
+ else
2672
+ el.setAttribute('value', next);
2673
+ return;
2674
+ }
2695
2675
  if (value == null || value === false)
2696
2676
  el.removeAttribute(key);
2697
2677
  else
@@ -1,14 +1,18 @@
1
1
  /**
2
- * Generic VMZ Node host — SSR file-route pages + dist static + RPC/REST.
2
+ * Generic VMZ Node host — SSR Route Graph pages + dist static + RPC/REST.
3
3
  *
4
4
  * Invoked by `vmz serve` / `vmz dev` (or: node dist/vmz-serve-host.mjs).
5
5
  *
6
- * Pathname `pages/**` (PascalCase stem → lowercase URL; `index` parent;
7
- * `[Param]` / `[...rest]` dynamic). Not an SPA shell.
6
+ * Pathname matches `vmz-deployment.json` `pathPattern` (explicit `<router>.path`
7
+ * or file-route default). Mini page stems are a different host projection.
8
+ * Not an SPA shell.
8
9
  *
9
10
  * `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
10
11
  * GET `/__vmz/events` SSE notifies the browser:
11
12
  * - island HMR → re-import `entry-client.js` (no full document reload)
12
13
  * - otherwise → `location.reload`
14
+ *
15
+ * Dev resolve hook propagates `?t=` onto nested relative `file:` imports under
16
+ * dist so soft reload does not keep a stale `lib/*.js` ESM cache entry.
13
17
  */
14
18
  export {};
@@ -1,28 +1,78 @@
1
1
  // @ts-nocheck
2
2
  /**
3
- * Generic VMZ Node host — SSR file-route pages + dist static + RPC/REST.
3
+ * Generic VMZ Node host — SSR Route Graph pages + dist static + RPC/REST.
4
4
  *
5
5
  * Invoked by `vmz serve` / `vmz dev` (or: node dist/vmz-serve-host.mjs).
6
6
  *
7
- * Pathname `pages/**` (PascalCase stem → lowercase URL; `index` parent;
8
- * `[Param]` / `[...rest]` dynamic). Not an SPA shell.
7
+ * Pathname matches `vmz-deployment.json` `pathPattern` (explicit `<router>.path`
8
+ * or file-route default). Mini page stems are a different host projection.
9
+ * Not an SPA shell.
9
10
  *
10
11
  * `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
11
12
  * GET `/__vmz/events` SSE notifies the browser:
12
13
  * - island HMR → re-import `entry-client.js` (no full document reload)
13
14
  * - otherwise → `location.reload`
15
+ *
16
+ * Dev resolve hook propagates `?t=` onto nested relative `file:` imports under
17
+ * dist so soft reload does not keep a stale `lib/*.js` ESM cache entry.
14
18
  */
15
19
  import { existsSync } from 'node:fs';
16
20
  import { readdir, readFile, writeFile } from 'node:fs/promises';
17
21
  import http from 'node:http';
22
+ import { createRequire, registerHooks } from 'node:module';
18
23
  import path from 'node:path';
19
24
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
25
  import { registerComponents, renderToStream, renderToString } from './vmz-dom.js';
21
26
  import { handleNodeRequest, setRoutes, setServerModuleResolver } from './vmz-runtime.js';
27
+ const require = createRequire(import.meta.url);
22
28
  const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
23
29
  const host = process.env.VMZ_HOST || '127.0.0.1';
24
30
  const port = Number(process.env.VMZ_PORT || process.env.PORT || 5173);
25
31
  const isDev = process.env.VMZ_DEV === '1' || process.env.VMZ_DEV === 'true';
32
+ // Absolute origin for in-process client graphs that fall back to HTTP RPC
33
+ // (separate `dist/vmz-runtime.js` instance without setServerModuleResolver).
34
+ globalThis.__VMZ_RPC_ORIGIN = `http://${host}:${port}`;
35
+ /**
36
+ * Soft reload only busts the top-level `import(page?t=token)`. Nested relative
37
+ * imports (`../../lib/units.js`) keep the first-loaded ESM cache entry — so a
38
+ * page can demand exports that the stale dep never had (or vice versa).
39
+ * Propagate `t` from parentURL onto file: children under this dist.
40
+ */
41
+ if (isDev) {
42
+ const distUrlPrefix = pathToFileURL(distDir.endsWith(path.sep) ? distDir : `${distDir}${path.sep}`).href;
43
+ registerHooks({
44
+ resolve(specifier, context, nextResolve) {
45
+ const result = nextResolve(specifier, context);
46
+ if (!specifier.startsWith('.') || !context.parentURL || !result?.url)
47
+ return result;
48
+ let token = '';
49
+ try {
50
+ token = new URL(context.parentURL).searchParams.get('t') || '';
51
+ }
52
+ catch {
53
+ return result;
54
+ }
55
+ if (!token)
56
+ return result;
57
+ if (!result.url.startsWith('file:'))
58
+ return result;
59
+ if (!result.url.startsWith(distUrlPrefix)) {
60
+ try {
61
+ if (!fileURLToPath(result.url).startsWith(distDir))
62
+ return result;
63
+ }
64
+ catch {
65
+ return result;
66
+ }
67
+ }
68
+ const u = new URL(result.url);
69
+ if (u.searchParams.get('t') === token)
70
+ return result;
71
+ u.searchParams.set('t', token);
72
+ return { ...result, url: u.href, shortCircuit: true };
73
+ },
74
+ });
75
+ }
26
76
  /** @type {number} */
27
77
  let reloadToken = Date.now();
28
78
  /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
@@ -44,6 +94,12 @@ let shuttingDown = false;
44
94
  let ready = false;
45
95
  /** @type {{ message: string, stack?: string, at: number } | null} */
46
96
  let lastDevError = null;
97
+ /**
98
+ * Native CodeGenerators handle — must be declared before top-level `await softReload()`
99
+ * (TDZ: requireNativeGenerator may run during that await).
100
+ * @type {any}
101
+ */
102
+ let _nativeGen;
47
103
  setServerModuleResolver((moduleId) => {
48
104
  const rel = moduleId.replace(/^#server\//, '') + '.js';
49
105
  return bustUrl(pathToFileURL(path.join(distDir, '#server', rel)).href);
@@ -277,11 +333,36 @@ function normalizeActionResult(acted) {
277
333
  * @param {string} marker
278
334
  */
279
335
  async function* emitAccessShell(marker) {
280
- yield `<!DOCTYPE html>
281
- <html lang="en">
282
- <head><meta charset="utf-8" /><title>VMZ</title></head>
283
- <body><p>${marker}</p></body>
284
- </html>`;
336
+ const native = requireNativeGenerator();
337
+ if (typeof native.generateHtmlShell !== 'function') {
338
+ throw new Error('vmz native addon missing generateHtmlShell — rebuild with `pnpm napi:build`');
339
+ }
340
+ yield native.generateHtmlShell({
341
+ title: 'App',
342
+ lang: 'en',
343
+ cssHrefs: [],
344
+ bodyHtml: `<p>${marker}</p>`,
345
+ bodyAttrs: [],
346
+ });
347
+ }
348
+ /**
349
+ * Prefer page `static meta()` for document title/description — never brand the framework in business HTML.
350
+ * @param {any} Page
351
+ */
352
+ function resolvePageDocumentMeta(Page) {
353
+ try {
354
+ let raw = {};
355
+ if (typeof Page?.meta === 'function')
356
+ raw = Page.meta() || {};
357
+ else if (Page?.meta && typeof Page.meta === 'object')
358
+ raw = Page.meta;
359
+ const title = String(raw.title || '').trim();
360
+ const description = String(raw.description || '').trim();
361
+ return { title: title || 'App', description };
362
+ }
363
+ catch {
364
+ return { title: 'App', description: '' };
365
+ }
285
366
  }
286
367
  /**
287
368
  * @param {any} Page
@@ -297,6 +378,12 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
297
378
  ? `\n <script>
298
379
  (() => {
299
380
  const es = new EventSource("/__vmz/events");
381
+ let sawDisconnect = false;
382
+ es.onerror = () => { sawDisconnect = true; };
383
+ es.onopen = () => {
384
+ // Host respawn drops SSE — reload once the new process is up (no manual restart).
385
+ if (sawDisconnect) location.reload();
386
+ };
300
387
  function showOverlay(err) {
301
388
  let el = document.getElementById("vmz-dev-overlay");
302
389
  if (!el) {
@@ -315,7 +402,7 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
315
402
  const stack = (err && err.stack) || "";
316
403
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
317
404
  el.innerHTML = "<div style=\\"max-width:56rem;margin:0 auto\\">"
318
- + "<p style=\\"margin:0 0 .5rem;color:#f87171;font-weight:700\\">VMZ Dev Error</p>"
405
+ + "<p style=\\"margin:0 0 .5rem;color:#f87171;font-weight:700\\">Dev Error</p>"
319
406
  + "<pre style=\\"white-space:pre-wrap;margin:0 0 1rem;font-size:13px;line-height:1.45\\">" + esc(msg) + "</pre>"
320
407
  + (stack ? "<pre style=\\"white-space:pre-wrap;opacity:.7;font-size:12px\\">" + esc(stack) + "</pre>" : "")
321
408
  + "<p style=\\"opacity:.65;font-size:12px\\">Fix the file and save — soft reload will clear this overlay.</p>"
@@ -387,70 +474,88 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
387
474
  `/* paint immediately */` +
388
475
  `var d=document.createElement("div");d.id="vmz-dev-overlay";d.setAttribute("role","alert");` +
389
476
  `Object.assign(d.style,{position:"fixed",inset:"0",zIndex:"2147483646",background:"rgba(15,17,21,0.92)",color:"#f4f4f5",fontFamily:"ui-monospace,monospace",padding:"2rem",overflow:"auto"});` +
390
- `d.innerHTML="<div style='max-width:56rem;margin:0 auto'><p style='color:#f87171;font-weight:700'>VMZ Dev Error</p><pre style='white-space:pre-wrap'>"+String(e.message||e).replace(/[<>&]/g,function(c){return {"<":"&lt;",">":"&gt;","&":"&amp;"}[c]})+"</pre></div>";` +
477
+ `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>";` +
391
478
  `document.documentElement.appendChild(d);})();</script>`
392
479
  : '';
393
480
  if (signal?.aborted)
394
481
  return;
395
482
  const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
396
- const htmlTheme = htmlThemeAttributeForId(themeId);
397
483
  const themeBoot = themeBootstrapScript();
398
- const cssLink = cssEntry ? ` <link rel="stylesheet" href="/${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}" />\n` : '';
399
484
  const propsJson = JSON.stringify(props ?? {});
400
- const layoutAttr = layoutChain.length ? ` data-vmz-layout="${escapeAttr(layoutChain.join(','))}"` : '';
401
485
  const localeId = localeCtx.localeId || localeArtifact?.defaultLocale || 'en';
402
486
  const dir = localeCtx.dir || 'ltr';
403
- const localeAttr = ` data-vmz-locale="${escapeAttr(localeId)}" data-vmz-dir="${escapeAttr(dir)}"`;
404
- const routingJson = localeArtifact?.routing
405
- ? escapeAttr(JSON.stringify({
487
+ /** @type {string[]} */
488
+ const htmlExtraAttrs = [...htmlThemeAttrPair(themeId)];
489
+ if (localeArtifact?.routing) {
490
+ htmlExtraAttrs.push('data-vmz-locale-routing', JSON.stringify({
406
491
  strategy: localeArtifact.routing.strategy || 'prefix',
407
492
  defaultPrefix: localeArtifact.routing.defaultPrefix || 'include',
408
493
  defaultLocale: localeArtifact.defaultLocale,
409
494
  locales: (localeArtifact.locales || []).map((l) => l.id),
410
- }))
411
- : '';
412
- const routingAttr = routingJson ? ` data-vmz-locale-routing="${routingJson}"` : '';
413
- const hreflangLinks = (localeCtx.alternates || [])
414
- .map((a) => ` <link rel="alternate" hreflang="${escapeAttr(a.hreflang)}" href="${escapeAttr(a.href)}" />`)
415
- .join('\n');
416
- const hreflangBlock = hreflangLinks ? `${hreflangLinks}\n` : '';
417
- yield `<!DOCTYPE html>
418
- <html lang="${escapeAttr(localeId)}" data-locale="${escapeAttr(localeId)}" dir="${escapeAttr(dir)}"${routingAttr}${htmlTheme}>
419
- <head>
420
- <meta charset="utf-8" />
421
- <meta name="viewport" content="width=device-width, initial-scale=1" />
422
- <title>VMZ</title>
423
- ${hreflangBlock}${themeBoot}${cssLink}</head>
424
- <body>
425
- <div id="app" data-vmz-page="${escapeAttr(chunkId)}"${layoutAttr}${localeAttr} data-vmz-props="${escapeAttr(propsJson)}">`;
426
- let bodyHtml = '';
427
- for await (const chunk of renderToStream(Page, props, { signal })) {
428
- if (signal?.aborted)
429
- return;
430
- bodyHtml += chunk;
495
+ }));
431
496
  }
432
- if (signal?.aborted)
433
- return;
434
- // Wrap page HTML in layout chain (outer → inner) via default slot injection.
435
- for (let i = layoutChain.length - 1; i >= 0; i--) {
436
- const Layout = await loadPageCtor(layoutChain[i]);
437
- if (!Layout)
438
- continue;
439
- bodyHtml = await renderToString(Layout, {}, { signal, slotHtml: bodyHtml });
497
+ const pageDocMeta = resolvePageDocumentMeta(Page);
498
+ const prevLocaleHint = globalThis.__vmzLocaleIdHint;
499
+ globalThis.__vmzLocaleIdHint = localeId;
500
+ let bodyHtml = '';
501
+ try {
502
+ for await (const chunk of renderToStream(Page, props, { signal })) {
503
+ if (signal?.aborted)
504
+ return;
505
+ bodyHtml += chunk;
506
+ }
440
507
  if (signal?.aborted)
441
508
  return;
509
+ // Wrap page HTML in layout chain (outer → inner) via default slot injection.
510
+ for (let i = layoutChain.length - 1; i >= 0; i--) {
511
+ const Layout = await loadPageCtor(layoutChain[i]);
512
+ if (!Layout)
513
+ continue;
514
+ bodyHtml = await renderToString(Layout, {}, { signal, slotHtml: bodyHtml });
515
+ if (signal?.aborted)
516
+ return;
517
+ }
518
+ // Locale discipline: same-app Links retain current LocaleId (realization authority).
519
+ if (localeArtifact && localeId) {
520
+ bodyHtml = localizeBodyLinksInHost(bodyHtml, localeId, localeArtifact);
521
+ }
442
522
  }
443
- // Locale discipline: same-app Links retain current LocaleId (realization authority).
444
- if (localeArtifact && localeId) {
445
- bodyHtml = localizeBodyLinksInHost(bodyHtml, localeId, localeArtifact);
523
+ finally {
524
+ if (prevLocaleHint === undefined)
525
+ delete globalThis.__vmzLocaleIdHint;
526
+ else
527
+ globalThis.__vmzLocaleIdHint = prevLocaleHint;
446
528
  }
447
- yield bodyHtml;
448
529
  if (signal?.aborted)
449
530
  return;
450
- yield `</div>
451
- <script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}${bootOverlay}
452
- </body>
453
- </html>`;
531
+ const native = requireNativeGenerator();
532
+ if (typeof native.generatePageShell !== 'function') {
533
+ throw new Error('vmz native addon missing generatePageShell — rebuild with `pnpm napi:build`');
534
+ }
535
+ const entrySrc = `/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}`;
536
+ const cssHref = cssEntry ? `${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}` : undefined;
537
+ yield native.generatePageShell({
538
+ bodyHtml,
539
+ chunkId,
540
+ layoutChain,
541
+ propsJson,
542
+ meta: {
543
+ title: pageDocMeta.title,
544
+ description: pageDocMeta.description,
545
+ canonical: '',
546
+ robots: '',
547
+ lang: localeId,
548
+ dir,
549
+ alternates: localeCtx.alternates || [],
550
+ },
551
+ // napi Option<String>: omit/undefined = None; null is rejected as String
552
+ ...(cssHref ? { cssEntry: cssHref } : {}),
553
+ isErrorDocument: false,
554
+ htmlExtraAttrs,
555
+ headExtraHtml: themeBoot,
556
+ moduleScriptSrc: entrySrc,
557
+ bodyTailHtml: `${live}${bootOverlay}`,
558
+ });
454
559
  }
455
560
  const server = http.createServer((req, res) => {
456
561
  const url = new URL(req.url || '/', `http://${host}:${port}`);
@@ -565,17 +670,20 @@ process.on('SIGINT', () => {
565
670
  * Re-import routes / pages / components with a new cache-bust token.
566
671
  * Keeps the HTTP server process alive (no Node restart).
567
672
  * Failed reloads keep the previous in-memory modules (Vite-like resilience).
568
- * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
673
+ * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], emitted?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
569
674
  */
570
675
  async function softReload(opts = {}) {
571
676
  const prevToken = reloadToken;
572
677
  const prevCatalog = pageCatalog;
678
+ const prevCtors = new Map(pageCtors);
573
679
  const nextToken = Date.now();
574
680
  reloadToken = nextToken;
575
681
  const affected = opts.payload?.affectedChunks ?? [];
576
682
  const seeds = opts.payload?.seedChunks ?? [];
683
+ const emitted = opts.payload?.emitted ?? [];
577
684
  const full = opts.payload?.full;
578
685
  const islandHmr = Boolean(opts.payload?.islandHmr);
686
+ const reloadAllPages = shouldReloadAllPages({ full, affected, emitted, islandHmr });
579
687
  try {
580
688
  try {
581
689
  const routes = JSON.parse(await readFile(path.join(distDir, 'vmz-routes.json'), 'utf8'));
@@ -613,7 +721,8 @@ async function softReload(opts = {}) {
613
721
  components[entry.name] = mod.default;
614
722
  }
615
723
  if (!islandHmr) {
616
- for (const p of nextCatalog) {
724
+ const pagesToLoad = reloadAllPages ? nextCatalog : nextCatalog.filter((p) => pageNeedsReload(p.chunkId, affected));
725
+ for (const p of pagesToLoad) {
617
726
  const pageRel = `${p.chunkId}.client.js`;
618
727
  const href = bustUrl(pathToFileURL(path.join(distDir, pageRel)).href);
619
728
  const mod = await import(href);
@@ -622,9 +731,21 @@ async function softReload(opts = {}) {
622
731
  }
623
732
  pageCatalog = nextCatalog;
624
733
  if (!islandHmr) {
625
- pageCtors.clear();
626
- for (const [k, v] of nextCtors)
627
- pageCtors.set(k, v);
734
+ if (reloadAllPages) {
735
+ pageCtors.clear();
736
+ for (const [k, v] of nextCtors)
737
+ pageCtors.set(k, v);
738
+ }
739
+ else {
740
+ // Keep unaffected page constructors; only swap what we re-imported.
741
+ for (const [k, v] of nextCtors)
742
+ pageCtors.set(k, v);
743
+ // Drop ctors for pages that disappeared from catalog.
744
+ for (const id of [...pageCtors.keys()]) {
745
+ if (!nextCatalog.some((p) => p.chunkId === id))
746
+ pageCtors.delete(id);
747
+ }
748
+ }
628
749
  }
629
750
  if (Object.keys(components).length) {
630
751
  registerComponents(components);
@@ -656,7 +777,8 @@ async function softReload(opts = {}) {
656
777
  }));
657
778
  if (!opts.quiet) {
658
779
  const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
659
- console.log(`vmz serve: soft reload ok (mode=${mode}; pages=${pageCatalog.length}; t=${reloadToken}${aff})`);
780
+ 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})`);
660
782
  }
661
783
  return {
662
784
  affectedChunks: affected,
@@ -666,15 +788,49 @@ async function softReload(opts = {}) {
666
788
  mode,
667
789
  eventOnlyShell,
668
790
  pageCount: pageCatalog.length,
791
+ reloadedPages: islandHmr ? 0 : nextCtors.size,
792
+ reloadAllPages,
669
793
  };
670
794
  }
671
795
  catch (err) {
672
796
  reloadToken = prevToken;
673
797
  pageCatalog = prevCatalog;
798
+ pageCtors.clear();
799
+ for (const [k, v] of prevCtors)
800
+ pageCtors.set(k, v);
674
801
  lastDevError = normalizeDevError(err);
675
802
  throw err;
676
803
  }
677
804
  }
805
+ /**
806
+ * Shared lib / full rebuild / missing affected list → refresh every page ctor.
807
+ * Otherwise only re-import the dirty page chunks (Vite-like module graph).
808
+ * @param {{ full?: boolean, affected: string[], emitted: string[], islandHmr: boolean }} opts
809
+ */
810
+ function shouldReloadAllPages(opts) {
811
+ if (opts.islandHmr)
812
+ return false;
813
+ if (opts.full)
814
+ return true;
815
+ if (!opts.affected.length)
816
+ return true;
817
+ for (const f of opts.emitted) {
818
+ const n = String(f).replace(/\\/g, '/');
819
+ if (n.includes('/lib/') || /\/Application\.client\.js$/.test(n) || /\/vmz-(dom|runtime|http|client-nav)\.js$/.test(n)) {
820
+ return true;
821
+ }
822
+ }
823
+ return false;
824
+ }
825
+ /** @param {string} chunkId @param {string[]} affected */
826
+ function pageNeedsReload(chunkId, affected) {
827
+ if (chunkId === 'pages/Layout' || chunkId.endsWith('/Layout'))
828
+ return true;
829
+ return affected.some((a) => {
830
+ const id = String(a);
831
+ return id === chunkId || chunkId.startsWith(`${id}/`) || id.startsWith(`${chunkId}/`);
832
+ });
833
+ }
678
834
  /** @param {string} event */
679
835
  function notifySse(event) {
680
836
  for (const client of [...sseClients]) {
@@ -702,23 +858,16 @@ function normalizeDevError(err) {
702
858
  async function* emitDevErrorHtml(err) {
703
859
  const msg = escapeHtml(err.message || 'Unknown error');
704
860
  const stack = err.stack ? escapeHtml(err.stack) : '';
705
- yield `<!DOCTYPE html>
706
- <html lang="en">
707
- <head>
708
- <meta charset="utf-8" />
709
- <meta name="viewport" content="width=device-width, initial-scale=1" />
710
- <title>VMZ Dev Error</title>
711
- <style>
861
+ const style = `<style>
712
862
  body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
713
863
  main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
714
864
  h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
715
865
  pre{white-space:pre-wrap;margin:0 0 1rem}
716
866
  .hint{opacity:.65;font-size:12px}
717
- </style>
718
- </head>
719
- <body>
867
+ </style>`;
868
+ const body = `${style}
720
869
  <main>
721
- <h1>VMZ Dev Error</h1>
870
+ <h1>Dev Error</h1>
722
871
  <pre>${msg}</pre>
723
872
  ${stack ? `<pre style="opacity:.7;font-size:12px">${stack}</pre>` : ''}
724
873
  <p class="hint">Dev host stayed up. Fix the source and save — soft reload will recover.</p>
@@ -732,9 +881,18 @@ async function* emitDevErrorHtml(err) {
732
881
  if (msg && msg.type === "hmr") location.reload();
733
882
  };
734
883
  })();
735
- </script>
736
- </body>
737
- </html>`;
884
+ </script>`;
885
+ const native = requireNativeGenerator();
886
+ if (typeof native.generateHtmlShell !== 'function') {
887
+ throw new Error('vmz native addon missing generateHtmlShell — rebuild with `pnpm napi:build`');
888
+ }
889
+ yield native.generateHtmlShell({
890
+ title: 'Dev Error',
891
+ lang: 'en',
892
+ cssHrefs: [],
893
+ bodyHtml: body,
894
+ bodyAttrs: [],
895
+ });
738
896
  }
739
897
  /** @param {string} s */
740
898
  function escapeHtml(s) {
@@ -919,10 +1077,14 @@ async function listClientComponents(dir) {
919
1077
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
920
1078
  }
921
1079
  /**
922
- * Discover compiled page modules under dist/pages.
1080
+ * Discover compiled page modules. Prefer Route Graph `pathPattern` from
1081
+ * `vmz-deployment.json`; fall back to walking `pages/**` (file-route only).
923
1082
  * @param {string} dir
924
1083
  */
925
1084
  async function listPageClientFiles(dir) {
1085
+ const fromDep = await listPagesFromDeployment(dir);
1086
+ if (fromDep.length)
1087
+ return fromDep;
926
1088
  const root = path.join(dir, 'pages');
927
1089
  /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
928
1090
  const out = [];
@@ -954,6 +1116,38 @@ async function listPageClientFiles(dir) {
954
1116
  await walk(root, []);
955
1117
  return out;
956
1118
  }
1119
+ /**
1120
+ * @param {string} dir
1121
+ */
1122
+ async function listPagesFromDeployment(dir) {
1123
+ /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
1124
+ const out = [];
1125
+ try {
1126
+ const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
1127
+ const dep = JSON.parse(raw);
1128
+ for (const unit of dep.units || []) {
1129
+ if (unit?.kind !== 'page')
1130
+ continue;
1131
+ const chunkId = String(unit.chunkId || '').replace(/\\/g, '/');
1132
+ if (!chunkId.startsWith('pages/'))
1133
+ continue;
1134
+ const stem = chunkId.split('/').pop() || '';
1135
+ if (isRouteBoundaryStem(stem))
1136
+ continue;
1137
+ const pageRel = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
1138
+ const pattern = String(unit.pathPattern || '').trim();
1139
+ out.push({
1140
+ chunkId,
1141
+ pageRel,
1142
+ segs: pattern ? parsePathPattern(pattern) : parseChunkSegments(chunkId),
1143
+ });
1144
+ }
1145
+ }
1146
+ catch {
1147
+ return [];
1148
+ }
1149
+ return out;
1150
+ }
957
1151
  /**
958
1152
  * File-route segments from chunk id (`pages/Install` → `/install`).
959
1153
  * Skips URL-invisible `(group)` dirs; boundary stems never reach here.
@@ -970,17 +1164,43 @@ function parseChunkSegments(chunkId) {
970
1164
  continue;
971
1165
  if (p === 'index' && i === parts.length - 1)
972
1166
  continue;
973
- const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
974
- const param = /^\[([^\]]+)\]$/.exec(p);
975
- if (catchAll)
976
- segs.push({ kind: 'catch', name: catchAll[1] });
977
- else if (param)
978
- segs.push({ kind: 'param', name: param[1] });
979
- else
980
- segs.push({ kind: 'static', value: p.toLowerCase() });
1167
+ segs.push(parsePathSegment(p));
1168
+ }
1169
+ return segs;
1170
+ }
1171
+ /**
1172
+ * Browser HTTP pattern (`/` / `/home` / `/users/:id` / `/blog/[...slug]`).
1173
+ * @param {string} pattern
1174
+ */
1175
+ function parsePathPattern(pattern) {
1176
+ const raw = String(pattern || '').trim();
1177
+ if (!raw || raw === '/')
1178
+ return [];
1179
+ const parts = raw.replace(/^\/+/, '').split('/').filter(Boolean);
1180
+ /** @type {Array<{ kind: 'static' | 'param' | 'catch', value?: string, name?: string }>} */
1181
+ const segs = [];
1182
+ for (const p of parts) {
1183
+ if (isRouteGroupDir(p))
1184
+ continue;
1185
+ segs.push(parsePathSegment(p));
981
1186
  }
982
1187
  return segs;
983
1188
  }
1189
+ /**
1190
+ * @param {string} p
1191
+ */
1192
+ function parsePathSegment(p) {
1193
+ const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
1194
+ const param = /^\[([^\]]+)\]$/.exec(p);
1195
+ const colon = /^:([A-Za-z_][\w]*)$/.exec(p);
1196
+ if (catchAll)
1197
+ return { kind: 'catch', name: catchAll[1] };
1198
+ if (param)
1199
+ return { kind: 'param', name: param[1] };
1200
+ if (colon)
1201
+ return { kind: 'param', name: colon[1] };
1202
+ return { kind: 'static', value: p.toLowerCase() };
1203
+ }
984
1204
  function isRouteGroupDir(seg) {
985
1205
  return typeof seg === 'string' && seg.startsWith('(') && seg.endsWith(')') && seg.length > 2;
986
1206
  }
@@ -1130,51 +1350,11 @@ async function runRouteGate(pathname, chunkId) {
1130
1350
  */
1131
1351
  function emitEntryClient(eager, lazy, token) {
1132
1352
  const q = `?t=${token}`;
1133
- const imports = eager.map((e) => `import ${e.name} from ${JSON.stringify(`./${e.entry}${q}`)};`).join('\n');
1134
- const map = eager.length ? `registerComponents({ ${eager.map((e) => e.name).join(', ')} });` : '';
1135
- const entryByName = Object.fromEntries([...eager, ...lazy].map((e) => [e.name, e.entry]));
1136
- const loader = lazy.length
1137
- ? `const __vmzComponentEntries = ${JSON.stringify(entryByName)};
1138
- globalThis.__vmzLoadComponent = async (name) => {
1139
- const entry = __vmzComponentEntries[name] || ("components/" + name + ".client.js");
1140
- const mod = await import("./" + entry + "${q}");
1141
- return mod.default;
1142
- };`
1143
- : '';
1144
- return `/**
1145
- * Generated by vmz serve — hydrate matched file-route page (data-vmz-page) + layout chain + client Link takeover.
1146
- */
1147
- import { registerComponents, hydrate, hydrateRoute, hydrateRoutePage, destroy } from ${JSON.stringify(`./vmz-dom.js${q}`)};
1148
- import { installClientNavigation } from ${JSON.stringify(`./vmz-client-nav.js${q}`)};
1149
- ${imports}
1150
-
1151
- ${map}
1152
- ${loader}
1153
-
1154
- const root = document.getElementById("app");
1155
- if (!root) throw new Error("vmz: missing #app");
1156
- const chunkId = root.getAttribute("data-vmz-page");
1157
- if (!chunkId) throw new Error("vmz: missing data-vmz-page");
1158
- let props = {};
1159
- try {
1160
- const raw = root.getAttribute("data-vmz-props");
1161
- if (raw) props = JSON.parse(raw);
1162
- } catch { /* ignore */ }
1163
- const layoutChain = (root.getAttribute("data-vmz-layout") || "").split(",").map((s) => s.trim()).filter(Boolean);
1164
- const layoutCtors = [];
1165
- for (const id of layoutChain) {
1166
- layoutCtors.push((await import("./" + id + ".client.js${q}")).default);
1167
- }
1168
- const Page = (await import("./" + chunkId + ".client.js${q}")).default;
1169
- await hydrateRoute(Page, root, props, layoutCtors);
1170
- installClientNavigation({
1171
- hydrate,
1172
- hydrateRoute,
1173
- hydrateRoutePage,
1174
- destroy,
1175
- importPage: async (id) => (await import("./" + id + ".client.js${q}")).default,
1176
- });
1177
- `;
1353
+ const native = requireNativeGenerator();
1354
+ if (typeof native.generateServeEntryClient !== 'function') {
1355
+ throw new Error('vmz native addon missing generateServeEntryClient — rebuild with `pnpm napi:build`');
1356
+ }
1357
+ return native.generateServeEntryClient(eager, lazy, q);
1178
1358
  }
1179
1359
  /**
1180
1360
  * EventEntry zero-framework bootstrap: no static import of vmz-dom / page / islands.
@@ -1183,35 +1363,80 @@ installClientNavigation({
1183
1363
  */
1184
1364
  function emitEntryEvent(token) {
1185
1365
  const q = `?t=${token}`;
1186
- return `/**
1187
- * Generated by vmz serve — EventEntry zero-framework JS shell.
1366
+ const native = requireNativeGenerator();
1367
+ if (typeof native.generateServeEntryEvent !== 'function') {
1368
+ throw new Error('vmz native addon missing generateServeEntryEvent — rebuild with `pnpm napi:build`');
1369
+ }
1370
+ return native.generateServeEntryEvent(q);
1371
+ }
1372
+ /**
1373
+ * Load vmz N-API CodeGenerators (same discovery as `@vmz/vmz` native-addon).
1374
+ * @returns {any}
1188
1375
  */
1189
- (async () => {
1190
- const roots = [...document.querySelectorAll(
1191
- '[data-vmz-entry="event"], [data-vmz-client="event"], [data-vmz-client^="event:"]',
1192
- )];
1193
- for (const el of roots) {
1194
- if (el.__vmzEventWired) continue;
1195
- el.__vmzEventWired = true;
1196
- const strat = el.getAttribute("data-vmz-client") || "event";
1197
- let type = "click";
1198
- if (strat.startsWith("event:") && strat.length > 6) type = strat.slice(6) || "click";
1199
- else if (strat === "click") type = "click";
1200
- el.addEventListener(
1201
- type,
1202
- async () => {
1203
- const { registerComponents, resume } = await import(${JSON.stringify(`./vmz-dom.js${q}`)});
1204
- const name = el.getAttribute("data-vmz-island");
1205
- if (!name) throw new Error("vmz: EventEntry missing data-vmz-island");
1206
- const Comp = (await import("./components/" + name + ".client.js${q}")).default;
1207
- registerComponents({ [name]: Comp });
1208
- await resume(Comp, el);
1209
- },
1210
- { once: true },
1211
- );
1212
- }
1213
- })();
1214
- `;
1376
+ function requireNativeGenerator() {
1377
+ if (_nativeGen !== undefined) {
1378
+ if (!_nativeGen) {
1379
+ throw new Error('vmz native addon missing — run `pnpm napi:build` (serve entry printers live in vmz-generator via N-API)');
1380
+ }
1381
+ return _nativeGen;
1382
+ }
1383
+ try {
1384
+ const envPath = (typeof process.env.VMZ_NATIVE_NODE === 'string' && process.env.VMZ_NATIVE_NODE.trim()) || '';
1385
+ if (envPath) {
1386
+ _nativeGen = require(path.resolve(envPath));
1387
+ return _nativeGen;
1388
+ }
1389
+ const { platform, arch } = process;
1390
+ let triple = `${platform}-${arch}`;
1391
+ if (platform === 'win32' && arch === 'x64')
1392
+ triple = 'win32-x64-msvc';
1393
+ else if (platform === 'win32' && arch === 'arm64')
1394
+ triple = 'win32-arm64-msvc';
1395
+ else if (platform === 'darwin' && arch === 'arm64')
1396
+ triple = 'darwin-arm64';
1397
+ else if (platform === 'darwin' && arch === 'x64')
1398
+ triple = 'darwin-x64';
1399
+ else if (platform === 'linux' && arch === 'x64')
1400
+ triple = 'linux-x64-gnu';
1401
+ else if (platform === 'linux' && arch === 'arm64')
1402
+ triple = 'linux-arm64-gnu';
1403
+ const short = triple === 'win32-x64-msvc'
1404
+ ? 'win32-x64'
1405
+ : triple === 'win32-arm64-msvc'
1406
+ ? 'win32-arm64'
1407
+ : triple === 'linux-x64-gnu'
1408
+ ? 'linux-x64'
1409
+ : triple === 'linux-arm64-gnu'
1410
+ ? 'linux-arm64'
1411
+ : triple;
1412
+ const name = `@vmz/vmz-${short}`;
1413
+ /** @type {string[]} */
1414
+ const candidates = [];
1415
+ try {
1416
+ const resolved = require.resolve(`${name}/package.json`);
1417
+ const dir = path.dirname(resolved);
1418
+ candidates.push(path.join(dir, `vmz.${triple}.node`), path.join(dir, 'vmz.node'));
1419
+ }
1420
+ catch {
1421
+ /* optional */
1422
+ }
1423
+ const here = path.dirname(fileURLToPath(import.meta.url));
1424
+ candidates.push(path.join(here, 'node_modules', name, `vmz.${triple}.node`), path.join(here, 'node_modules', name, 'vmz.node'), path.join(here, '..', 'node_modules', name, `vmz.${triple}.node`), path.join(here, '..', 'node_modules', name, 'vmz.node'));
1425
+ for (const p of candidates) {
1426
+ if (existsSync(p)) {
1427
+ _nativeGen = require(p);
1428
+ return _nativeGen;
1429
+ }
1430
+ }
1431
+ _nativeGen = null;
1432
+ }
1433
+ catch {
1434
+ _nativeGen = null;
1435
+ }
1436
+ if (!_nativeGen) {
1437
+ throw new Error('vmz native addon missing — run `pnpm napi:build` (serve entry printers live in vmz-generator via N-API)');
1438
+ }
1439
+ return _nativeGen;
1215
1440
  }
1216
1441
  /**
1217
1442
  * Style Theme cookie / localStorage key (host contract, not a second theme API).
@@ -1269,14 +1494,15 @@ function resolveThemeId(searchParams, cookieHeader) {
1269
1494
  /**
1270
1495
  * Always emit activation attr for an explicit theme id (incl. default) so it overrides OS media.
1271
1496
  * @param {string|null} themeId
1497
+ * @returns {[string, string] | []} flattened attr pair for generatePageShell
1272
1498
  */
1273
- function htmlThemeAttributeForId(themeId) {
1499
+ function htmlThemeAttrPair(themeId) {
1274
1500
  if (!styleTheme || !themeId)
1275
- return '';
1501
+ return [];
1276
1502
  const attr = styleTheme.activationAttr || 'data-theme';
1277
1503
  if (!(styleTheme.themeIds || []).includes(themeId))
1278
- return '';
1279
- return ` ${attr}="${escapeAttr(themeId)}"`;
1504
+ return [];
1505
+ return [attr, themeId];
1280
1506
  }
1281
1507
  /**
1282
1508
  * Inline boot when SSR had no query/cookie: apply explicit `localStorage` only.
package/dist/server.js CHANGED
@@ -75,7 +75,21 @@ async function callServerLocal(moduleId, method, args) {
75
75
  * @param {unknown[]} args
76
76
  */
77
77
  async function callServerHttp(moduleId, method, args) {
78
- const rpcPath = (typeof globalThis !== 'undefined' && globalThis.__VMZ_RPC_PATH) || DEFAULT_RPC_PATH;
78
+ let rpcPath = (typeof globalThis !== 'undefined' && globalThis.__VMZ_RPC_PATH) || DEFAULT_RPC_PATH;
79
+ // Node undici `fetch` rejects relative URLs; browsers accept path-only.
80
+ if (typeof rpcPath === 'string' && !/^https?:\/\//i.test(rpcPath)) {
81
+ const origin = (typeof globalThis !== 'undefined' && globalThis.__VMZ_RPC_ORIGIN) ||
82
+ (typeof window !== 'undefined' && window.location && window.location.origin) ||
83
+ null;
84
+ if (origin) {
85
+ rpcPath = new URL(rpcPath, origin).href;
86
+ }
87
+ else if (typeof window === 'undefined') {
88
+ const host = (typeof process !== 'undefined' && (process.env.VMZ_HOST || process.env.HOST)) || '127.0.0.1';
89
+ const port = (typeof process !== 'undefined' && (process.env.VMZ_PORT || process.env.PORT)) || '5173';
90
+ rpcPath = new URL(rpcPath, `http://${host}:${port}`).href;
91
+ }
92
+ }
79
93
  const res = await fetch(rpcPath, {
80
94
  method: 'POST',
81
95
  headers: { 'content-type': 'application/json' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
6
  "exports": {