@vmz/core 0.0.3 → 0.1.0

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.
@@ -12,12 +12,13 @@
12
12
  * - island HMR → re-import `entry-client.js` (no full document reload)
13
13
  * - otherwise → `location.reload`
14
14
  */
15
+ import { existsSync } from 'node:fs';
16
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
15
17
  import http from 'node:http';
16
18
  import path from 'node:path';
17
- import { readdir, writeFile, readFile } from 'node:fs/promises';
18
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
- import { setServerModuleResolver, setRoutes, handleNodeRequest } from './vmz-runtime.js';
20
- import { registerComponents, renderToStream } from './vmz-dom.js';
20
+ import { registerComponents, renderToStream, renderToString } from './vmz-dom.js';
21
+ import { handleNodeRequest, setRoutes, setServerModuleResolver } from './vmz-runtime.js';
21
22
  const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
22
23
  const host = process.env.VMZ_HOST || '127.0.0.1';
23
24
  const port = Number(process.env.VMZ_PORT || process.env.PORT || 5173);
@@ -32,13 +33,30 @@ const pageCtors = new Map();
32
33
  let cssEntry = null;
33
34
  /** @type {{ defaultThemeId: string, themeIds: string[], activationAttr: string, contentHash: string|null } | null} */
34
35
  let styleTheme = null;
36
+ /** Locale route realization artifact from `_vmz/locale-route-realization.json` (optional). */
37
+ let localeArtifact = null;
35
38
  /** @type {Set<import('node:http').ServerResponse>} */
36
39
  const sseClients = new Set();
40
+ /** In-flight HTTP requests (graceful shutdown drain). */
41
+ let inFlight = 0;
42
+ /** When true, refuse new work except health. */
43
+ let shuttingDown = false;
44
+ let ready = false;
45
+ /** @type {{ message: string, stack?: string, at: number } | null} */
46
+ let lastDevError = null;
37
47
  setServerModuleResolver((moduleId) => {
38
48
  const rel = moduleId.replace(/^#server\//, '') + '.js';
39
49
  return bustUrl(pathToFileURL(path.join(distDir, '#server', rel)).href);
40
50
  });
41
- await softReload({ quiet: true });
51
+ try {
52
+ await softReload({ quiet: true });
53
+ ready = true;
54
+ }
55
+ catch (err) {
56
+ lastDevError = normalizeDevError(err);
57
+ ready = true; // still accept HTTP — serve error page / recover on next reload
58
+ console.error('vmz serve: initial load failed (dev host stays up)', lastDevError.message);
59
+ }
42
60
  /**
43
61
  * @param {import('node:http').IncomingMessage} req
44
62
  * @returns {Promise<string>}
@@ -68,14 +86,24 @@ async function renderPage(pathname, opts = {}) {
68
86
  }
69
87
  /**
70
88
  * Stream shell + Direct serialize body for the matched file-route page.
89
+ * Runs Page.access (closed allow/redirect/not-found/deny) before load;
90
+ * POST may run Page.action before re-render.
71
91
  * @param {string} pathname
72
- * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
73
- * @returns {Promise<{ status: number, stream: AsyncGenerator<string, void, void> } | null>}
92
+ * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string, method?: string, body?: unknown }} [opts]
93
+ * @returns {Promise<{ status: number, stream?: AsyncGenerator<string, void, void>, redirect?: string, headers?: Record<string, string> } | null>}
74
94
  */
75
95
  async function renderPageStream(pathname, opts = {}) {
76
- let match = matchFileRoute(pathname, pageCatalog);
96
+ if (isDev && lastDevError && pageCtors.size === 0) {
97
+ return { status: 500, stream: emitDevErrorHtml(lastDevError) };
98
+ }
99
+ const localePlan = resolveLocalePath(pathname);
100
+ if (localePlan.redirectTo) {
101
+ return { status: 302, redirect: localePlan.redirectTo, headers: { Location: localePlan.redirectTo } };
102
+ }
103
+ const routePath = localePlan.restPath || pathname;
104
+ let match = matchFileRoute(routePath, pageCatalog);
77
105
  let status = 200;
78
- const gated = await runRouteGate(pathname, match?.chunkId);
106
+ const gated = await runRouteGate(routePath, match?.chunkId);
79
107
  if (gated === 'not_found') {
80
108
  match = findRootCatchAll(pageCatalog);
81
109
  status = 404;
@@ -87,38 +115,228 @@ async function renderPageStream(pathname, opts = {}) {
87
115
  else if (isRootCatchAll(match)) {
88
116
  status = 404;
89
117
  }
90
- if (!match)
118
+ if (!match) {
119
+ if (isDev && lastDevError) {
120
+ return { status: 500, stream: emitDevErrorHtml(lastDevError) };
121
+ }
91
122
  return null;
123
+ }
92
124
  const Page = await loadPageCtor(match.chunkId);
93
- if (!Page)
125
+ if (!Page) {
126
+ if (isDev && lastDevError) {
127
+ return { status: 500, stream: emitDevErrorHtml(lastDevError) };
128
+ }
94
129
  return null;
130
+ }
131
+ const params = extractRouteParams(match.segs, routePath);
132
+ const method = String(opts.method || 'GET').toUpperCase();
133
+ const localeCtx = {
134
+ localeId: localePlan.localeId,
135
+ dir: localePlan.dir,
136
+ pathname,
137
+ routePath,
138
+ alternates: pageMetaAlternates(match.chunkId, localePlan.localeId),
139
+ };
140
+ if (typeof Page.access === 'function') {
141
+ const access = await Page.access({
142
+ params,
143
+ pathname: routePath,
144
+ chunkId: match.chunkId,
145
+ signal: opts.signal,
146
+ searchParams: opts.searchParams,
147
+ method,
148
+ localeId: localeCtx.localeId,
149
+ });
150
+ const closed = normalizeAccessResult(access);
151
+ if (closed.kind === 'redirect') {
152
+ return { status: 302, redirect: closed.location, headers: { Location: closed.location } };
153
+ }
154
+ if (closed.kind === 'deny') {
155
+ return { status: 403, stream: emitAccessShell('route-access-deny') };
156
+ }
157
+ if (closed.kind === 'not-found') {
158
+ const catchAll = findRootCatchAll(pageCatalog);
159
+ if (catchAll) {
160
+ const NotFound = await loadPageCtor(catchAll.chunkId);
161
+ if (NotFound) {
162
+ const resumeEntries = await loadPageResumeEntries(distDir, catchAll.chunkId);
163
+ const eventOnlyShell = isEventOnlyShell(resumeEntries.map((e) => e.strategy));
164
+ return {
165
+ status: 404,
166
+ stream: emitPageHtml(NotFound, catchAll.chunkId, eventOnlyShell, { ...params }, opts, [], localeCtx),
167
+ };
168
+ }
169
+ }
170
+ return { status: 404, stream: emitAccessShell('route-access-not-found') };
171
+ }
172
+ }
173
+ let props = { ...params };
174
+ if (method === 'POST' && typeof Page.action === 'function') {
175
+ const acted = await Page.action({
176
+ params,
177
+ pathname: routePath,
178
+ chunkId: match.chunkId,
179
+ signal: opts.signal,
180
+ searchParams: opts.searchParams,
181
+ body: opts.body,
182
+ method,
183
+ localeId: localeCtx.localeId,
184
+ });
185
+ const actionClosed = normalizeActionResult(acted);
186
+ if (actionClosed.kind === 'redirect') {
187
+ return { status: 302, redirect: actionClosed.location, headers: { Location: actionClosed.location } };
188
+ }
189
+ if (actionClosed.kind === 'deny') {
190
+ return { status: 403, stream: emitAccessShell('route-action-deny') };
191
+ }
192
+ if (actionClosed.kind === 'not-found') {
193
+ return { status: 404, stream: emitAccessShell('route-action-not-found') };
194
+ }
195
+ if (actionClosed.props) {
196
+ props = { ...props, ...actionClosed.props };
197
+ }
198
+ }
199
+ if (typeof Page.load === 'function') {
200
+ const loaded = await Page.load({
201
+ params,
202
+ pathname: routePath,
203
+ chunkId: match.chunkId,
204
+ signal: opts.signal,
205
+ searchParams: opts.searchParams,
206
+ localeId: localeCtx.localeId,
207
+ });
208
+ if (opts.signal?.aborted) {
209
+ return { status: 499, stream: emitAccessShell('route-nav-cancelled') };
210
+ }
211
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) {
212
+ props = { ...props, ...loaded };
213
+ }
214
+ }
215
+ if (opts.signal?.aborted) {
216
+ return { status: 499, stream: emitAccessShell('route-nav-cancelled') };
217
+ }
95
218
  const resumeEntries = await loadPageResumeEntries(distDir, match.chunkId);
96
219
  const strategies = resumeEntries.map((e) => e.strategy);
97
220
  const eventOnlyShell = isEventOnlyShell(strategies);
221
+ const layoutChain = resolveLayoutChain(match.chunkId);
98
222
  return {
99
223
  status,
100
- stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, opts),
224
+ stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, props, opts, layoutChain, localeCtx),
101
225
  };
102
226
  }
227
+ /**
228
+ * @param {unknown} access
229
+ * @returns {{ kind: 'allow' } | { kind: 'redirect', location: string } | { kind: 'deny' } | { kind: 'not-found' }}
230
+ */
231
+ function normalizeAccessResult(access) {
232
+ if (access == null || access === true)
233
+ return { kind: 'allow' };
234
+ if (typeof access === 'string') {
235
+ const k = access.toLowerCase();
236
+ if (k === 'allow')
237
+ return { kind: 'allow' };
238
+ if (k === 'deny')
239
+ return { kind: 'deny' };
240
+ if (k === 'not-found' || k === 'notfound')
241
+ return { kind: 'not-found' };
242
+ }
243
+ if (typeof access === 'object') {
244
+ const kind = String(access.kind || access.type || 'allow').toLowerCase();
245
+ if (kind === 'redirect') {
246
+ const location = String(access.location || access.to || access.href || '');
247
+ if (!location)
248
+ return { kind: 'deny' };
249
+ return { kind: 'redirect', location };
250
+ }
251
+ if (kind === 'deny')
252
+ return { kind: 'deny' };
253
+ if (kind === 'not-found' || kind === 'notfound')
254
+ return { kind: 'not-found' };
255
+ return { kind: 'allow' };
256
+ }
257
+ return { kind: 'allow' };
258
+ }
259
+ /**
260
+ * @param {unknown} acted
261
+ * @returns {{ kind: 'allow', props?: Record<string, unknown> } | { kind: 'redirect', location: string } | { kind: 'deny' } | { kind: 'not-found' }}
262
+ */
263
+ function normalizeActionResult(acted) {
264
+ const base = normalizeAccessResult(acted);
265
+ if (base.kind !== 'allow')
266
+ return base;
267
+ if (acted && typeof acted === 'object' && acted.props && typeof acted.props === 'object') {
268
+ return { kind: 'allow', props: acted.props };
269
+ }
270
+ if (acted && typeof acted === 'object' && !('kind' in acted) && !('type' in acted) && !Array.isArray(acted)) {
271
+ return { kind: 'allow', props: acted };
272
+ }
273
+ return { kind: 'allow' };
274
+ }
275
+ /**
276
+ * Minimal HTML for closed access/action results when no NotFound page exists.
277
+ * @param {string} marker
278
+ */
279
+ 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>`;
285
+ }
103
286
  /**
104
287
  * @param {any} Page
105
288
  * @param {string} chunkId
106
289
  * @param {boolean} eventOnlyShell
290
+ * @param {Record<string, unknown>} props
107
291
  * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
292
+ * @param {string[]} [layoutChain] layout chunk ids outer→inner
108
293
  */
109
- async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
294
+ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {}, layoutChain = [], localeCtx = {}) {
110
295
  const signal = opts.signal;
111
296
  const live = isDev
112
297
  ? `\n <script>
113
298
  (() => {
114
299
  const es = new EventSource("/__vmz/events");
300
+ function showOverlay(err) {
301
+ let el = document.getElementById("vmz-dev-overlay");
302
+ if (!el) {
303
+ el = document.createElement("div");
304
+ el.id = "vmz-dev-overlay";
305
+ el.setAttribute("role", "alert");
306
+ Object.assign(el.style, {
307
+ position: "fixed", inset: "0", zIndex: "2147483646",
308
+ background: "rgba(15,17,21,0.92)", color: "#f4f4f5",
309
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
310
+ padding: "2rem", overflow: "auto",
311
+ });
312
+ document.documentElement.appendChild(el);
313
+ }
314
+ const msg = (err && err.message) || String(err || "Unknown error");
315
+ const stack = (err && err.stack) || "";
316
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
317
+ 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>"
319
+ + "<pre style=\\"white-space:pre-wrap;margin:0 0 1rem;font-size:13px;line-height:1.45\\">" + esc(msg) + "</pre>"
320
+ + (stack ? "<pre style=\\"white-space:pre-wrap;opacity:.7;font-size:12px\\">" + esc(stack) + "</pre>" : "")
321
+ + "<p style=\\"opacity:.65;font-size:12px\\">Fix the file and save — soft reload will clear this overlay.</p>"
322
+ + "</div>";
323
+ }
324
+ function hideOverlay() {
325
+ const el = document.getElementById("vmz-dev-overlay");
326
+ if (el) el.remove();
327
+ }
115
328
  es.onmessage = async (ev) => {
116
329
  let msg = null;
117
330
  try { msg = JSON.parse(ev.data); } catch { /* plain string */ }
331
+ if (msg && msg.type === "error") {
332
+ showOverlay(msg);
333
+ return;
334
+ }
118
335
  if (!msg || msg.type !== "hmr") {
119
336
  if (ev.data === "reload") location.reload();
120
337
  return;
121
338
  }
339
+ hideOverlay();
122
340
  if (msg.mode === "island") {
123
341
  try {
124
342
  const { registerComponents, hydrate } = await import("/vmz-dom.js?t=" + msg.token);
@@ -142,13 +360,18 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
142
360
  const pageChunk = root && root.getAttribute("data-vmz-page");
143
361
  if (root && pageChunk) {
144
362
  const pageMod = await import("/" + pageChunk + ".client.js?t=" + msg.token);
145
- await hydrate(pageMod.default, root, {}, { preserveState: true, skipOnMount: true });
363
+ let hmrProps = {};
364
+ try {
365
+ const raw = root.getAttribute("data-vmz-props");
366
+ if (raw) hmrProps = JSON.parse(raw);
367
+ } catch { /* ignore */ }
368
+ await hydrate(pageMod.default, root, hmrProps, { preserveState: true, skipOnMount: true });
146
369
  } else {
147
370
  location.reload();
148
371
  }
149
372
  } catch (err) {
150
373
  console.error("vmz island HMR failed", err);
151
- location.reload();
374
+ showOverlay({ message: String(err && err.message || err), stack: err && err.stack });
152
375
  }
153
376
  return;
154
377
  }
@@ -157,35 +380,110 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
157
380
  })();
158
381
  </script>`
159
382
  : '';
383
+ const bootOverlay = isDev && lastDevError
384
+ ? `\n <script>window.__VMZ_DEV_ERROR__=${JSON.stringify(lastDevError)};` +
385
+ `(function(){var e=window.__VMZ_DEV_ERROR__;if(!e)return;` +
386
+ `var ev=new Event("message");ev.data=JSON.stringify({type:"error",message:e.message,stack:e.stack});` +
387
+ `/* paint immediately */` +
388
+ `var d=document.createElement("div");d.id="vmz-dev-overlay";d.setAttribute("role","alert");` +
389
+ `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>";` +
391
+ `document.documentElement.appendChild(d);})();</script>`
392
+ : '';
160
393
  if (signal?.aborted)
161
394
  return;
162
395
  const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
163
396
  const htmlTheme = htmlThemeAttributeForId(themeId);
164
397
  const themeBoot = themeBootstrapScript();
165
398
  const cssLink = cssEntry ? ` <link rel="stylesheet" href="/${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}" />\n` : '';
399
+ const propsJson = JSON.stringify(props ?? {});
400
+ const layoutAttr = layoutChain.length ? ` data-vmz-layout="${escapeAttr(layoutChain.join(','))}"` : '';
401
+ const localeId = localeCtx.localeId || localeArtifact?.defaultLocale || 'en';
402
+ 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({
406
+ strategy: localeArtifact.routing.strategy || 'prefix',
407
+ defaultPrefix: localeArtifact.routing.defaultPrefix || 'include',
408
+ defaultLocale: localeArtifact.defaultLocale,
409
+ 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` : '';
166
417
  yield `<!DOCTYPE html>
167
- <html lang="en"${htmlTheme}>
418
+ <html lang="${escapeAttr(localeId)}" data-locale="${escapeAttr(localeId)}" dir="${escapeAttr(dir)}"${routingAttr}${htmlTheme}>
168
419
  <head>
169
420
  <meta charset="utf-8" />
170
421
  <meta name="viewport" content="width=device-width, initial-scale=1" />
171
422
  <title>VMZ</title>
172
- ${themeBoot}${cssLink}</head>
423
+ ${hreflangBlock}${themeBoot}${cssLink}</head>
173
424
  <body>
174
- <div id="app" data-vmz-page="${escapeAttr(chunkId)}">`;
175
- for await (const chunk of renderToStream(Page, {}, { signal })) {
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 })) {
176
428
  if (signal?.aborted)
177
429
  return;
178
- yield chunk;
430
+ bodyHtml += chunk;
179
431
  }
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 });
440
+ if (signal?.aborted)
441
+ return;
442
+ }
443
+ // Locale discipline: same-app Links retain current LocaleId (realization authority).
444
+ if (localeArtifact && localeId) {
445
+ bodyHtml = localizeBodyLinksInHost(bodyHtml, localeId, localeArtifact);
446
+ }
447
+ yield bodyHtml;
180
448
  if (signal?.aborted)
181
449
  return;
182
450
  yield `</div>
183
- <script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}
451
+ <script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}${bootOverlay}
184
452
  </body>
185
453
  </html>`;
186
454
  }
187
455
  const server = http.createServer((req, res) => {
188
456
  const url = new URL(req.url || '/', `http://${host}:${port}`);
457
+ if (url.pathname === '/__vmz/health' && req.method === 'GET') {
458
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
459
+ res.end(JSON.stringify({ status: 'ok', shuttingDown, inFlight }));
460
+ return;
461
+ }
462
+ if (url.pathname === '/__vmz/ready' && req.method === 'GET') {
463
+ if (!ready || shuttingDown) {
464
+ res.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
465
+ res.end(JSON.stringify({ status: 'not-ready', ready, shuttingDown }));
466
+ return;
467
+ }
468
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
469
+ res.end(JSON.stringify({ status: 'ready', ready: true, inFlight }));
470
+ return;
471
+ }
472
+ if (shuttingDown) {
473
+ res.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
474
+ res.end(JSON.stringify({ status: 'shutting-down' }));
475
+ return;
476
+ }
477
+ inFlight += 1;
478
+ let settled = false;
479
+ const done = () => {
480
+ if (settled)
481
+ return;
482
+ settled = true;
483
+ inFlight = Math.max(0, inFlight - 1);
484
+ };
485
+ res.on('finish', done);
486
+ res.on('close', done);
189
487
  if (url.pathname === '/__vmz/reload' && req.method === 'POST') {
190
488
  readRequestBody(req)
191
489
  .then((raw) => {
@@ -204,8 +502,15 @@ const server = http.createServer((req, res) => {
204
502
  })
205
503
  .catch((err) => {
206
504
  console.error('vmz serve: soft reload failed', err);
505
+ lastDevError = normalizeDevError(err);
506
+ notifySse(JSON.stringify({
507
+ type: 'error',
508
+ message: lastDevError.message,
509
+ stack: lastDevError.stack,
510
+ at: lastDevError.at,
511
+ }));
207
512
  res.writeHead(500, { 'content-type': 'application/json' });
208
- res.end(JSON.stringify({ ok: false, error: String(err) }));
513
+ res.end(JSON.stringify({ ok: false, error: lastDevError.message }));
209
514
  });
210
515
  return;
211
516
  }
@@ -227,91 +532,148 @@ const server = http.createServer((req, res) => {
227
532
  server.listen(port, host, () => {
228
533
  console.log(`vmz serve http://${host}:${port} (dist=${distDir}${isDev ? ', dev' : ''})`);
229
534
  });
535
+ const SHUTDOWN_TIMEOUT_MS = Number(process.env.VMZ_SHUTDOWN_TIMEOUT_MS || 10000);
536
+ async function gracefulShutdown(signal) {
537
+ if (shuttingDown)
538
+ return;
539
+ shuttingDown = true;
540
+ ready = false;
541
+ console.log(`vmz serve: ${signal} — draining in-flight=${inFlight} timeout=${SHUTDOWN_TIMEOUT_MS}ms`);
542
+ server.close();
543
+ const start = Date.now();
544
+ while (inFlight > 0 && Date.now() - start < SHUTDOWN_TIMEOUT_MS) {
545
+ await new Promise((r) => setTimeout(r, 25));
546
+ }
547
+ for (const client of sseClients) {
548
+ try {
549
+ client.end();
550
+ }
551
+ catch {
552
+ /* ignore */
553
+ }
554
+ }
555
+ sseClients.clear();
556
+ process.exit(inFlight > 0 ? 1 : 0);
557
+ }
558
+ process.on('SIGTERM', () => {
559
+ void gracefulShutdown('SIGTERM');
560
+ });
561
+ process.on('SIGINT', () => {
562
+ void gracefulShutdown('SIGINT');
563
+ });
230
564
  /**
231
565
  * Re-import routes / pages / components with a new cache-bust token.
232
566
  * Keeps the HTTP server process alive (no Node restart).
567
+ * Failed reloads keep the previous in-memory modules (Vite-like resilience).
233
568
  * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
234
569
  */
235
570
  async function softReload(opts = {}) {
236
- reloadToken = Date.now();
571
+ const prevToken = reloadToken;
572
+ const prevCatalog = pageCatalog;
573
+ const nextToken = Date.now();
574
+ reloadToken = nextToken;
237
575
  const affected = opts.payload?.affectedChunks ?? [];
238
576
  const seeds = opts.payload?.seedChunks ?? [];
239
577
  const full = opts.payload?.full;
240
578
  const islandHmr = Boolean(opts.payload?.islandHmr);
241
579
  try {
242
- const routes = JSON.parse(await readFile(path.join(distDir, 'vmz-routes.json'), 'utf8'));
243
- setRoutes(routes);
244
- }
245
- catch {
246
- setRoutes([]);
247
- }
248
- const componentEntries = await listClientComponents(distDir);
249
- const componentNames = componentEntries.map((e) => e.name);
250
- pageCatalog = await listPageClientFiles(distDir);
251
- if (!pageCatalog.length) {
252
- throw new Error(`vmz serve: no pages/**/*.client.js in ${distDir}`);
253
- }
254
- pageCtors.clear();
255
- /** @type {Record<string, any>} */
256
- const components = {};
257
- const affectedNames = new Set(affected
258
- .map((c) => String(c))
259
- .filter((c) => c.startsWith('components/') || !c.includes('/'))
260
- .map((c) => c.split('/').pop())
261
- .filter(Boolean));
262
- for (const entry of componentEntries) {
263
- if (islandHmr && affectedNames.size > 0 && !affectedNames.has(entry.name)) {
264
- continue;
580
+ try {
581
+ const routes = JSON.parse(await readFile(path.join(distDir, 'vmz-routes.json'), 'utf8'));
582
+ setRoutes(routes);
265
583
  }
266
- const href = bustUrl(pathToFileURL(path.join(distDir, entry.entry)).href);
267
- const mod = await import(href);
268
- components[entry.name] = mod.default;
269
- }
270
- if (Object.keys(components).length) {
271
- registerComponents(components);
272
- }
273
- if (!islandHmr) {
274
- for (const p of pageCatalog) {
275
- await loadPageCtor(p.chunkId);
584
+ catch {
585
+ setRoutes([]);
586
+ }
587
+ try {
588
+ localeArtifact = JSON.parse(await readFile(path.join(distDir, '_vmz', 'locale-route-realization.json'), 'utf8'));
589
+ }
590
+ catch {
591
+ localeArtifact = null;
592
+ }
593
+ const componentEntries = await listClientComponents(distDir);
594
+ const nextCatalog = await listPageClientFiles(distDir);
595
+ if (!nextCatalog.length) {
596
+ throw new Error(`vmz serve: no pages/**/*.client.js in ${distDir}`);
597
+ }
598
+ /** @type {Record<string, any>} */
599
+ const components = {};
600
+ /** @type {Map<string, any>} */
601
+ const nextCtors = new Map();
602
+ const affectedNames = new Set(affected
603
+ .map((c) => String(c))
604
+ .filter((c) => c.startsWith('components/') || !c.includes('/'))
605
+ .map((c) => c.split('/').pop())
606
+ .filter(Boolean));
607
+ for (const entry of componentEntries) {
608
+ if (islandHmr && affectedNames.size > 0 && !affectedNames.has(entry.name)) {
609
+ continue;
610
+ }
611
+ const href = bustUrl(pathToFileURL(path.join(distDir, entry.entry)).href);
612
+ const mod = await import(href);
613
+ components[entry.name] = mod.default;
276
614
  }
615
+ if (!islandHmr) {
616
+ for (const p of nextCatalog) {
617
+ const pageRel = `${p.chunkId}.client.js`;
618
+ const href = bustUrl(pathToFileURL(path.join(distDir, pageRel)).href);
619
+ const mod = await import(href);
620
+ nextCtors.set(p.chunkId, mod.default);
621
+ }
622
+ }
623
+ pageCatalog = nextCatalog;
624
+ if (!islandHmr) {
625
+ pageCtors.clear();
626
+ for (const [k, v] of nextCtors)
627
+ pageCtors.set(k, v);
628
+ }
629
+ if (Object.keys(components).length) {
630
+ registerComponents(components);
631
+ }
632
+ const indexChunk = pageCatalog.find((p) => p.chunkId === 'pages/index')?.chunkId || pageCatalog[0].chunkId;
633
+ const resumeEntries = await loadPageResumeEntries(distDir, indexChunk);
634
+ const styleMeta = await loadDeploymentStyle(distDir);
635
+ cssEntry = styleMeta.cssEntry;
636
+ styleTheme = styleMeta.styleTheme;
637
+ const lazyEventNames = resumeEntries
638
+ .filter((e) => isEventStrategy(e.strategy))
639
+ .map((e) => e.component)
640
+ .filter(Boolean);
641
+ const lazySet = new Set(lazyEventNames);
642
+ await writeFile(path.join(distDir, 'entry-client.js'), emitEntryClient(componentEntries.filter((e) => !lazySet.has(e.name)), componentEntries.filter((e) => lazySet.has(e.name)), reloadToken), 'utf8');
643
+ const strategies = resumeEntries.map((e) => e.strategy);
644
+ const eventOnlyShell = isEventOnlyShell(strategies);
645
+ await writeFile(path.join(distDir, 'entry-event.js'), emitEntryEvent(reloadToken), 'utf8');
646
+ lastDevError = null;
647
+ const mode = islandHmr ? 'island' : eventOnlyShell ? 'event-shell' : 'full';
648
+ notifySse(JSON.stringify({
649
+ type: 'hmr',
650
+ mode,
651
+ affectedChunks: affected,
652
+ seedChunks: seeds,
653
+ token: reloadToken,
654
+ full: Boolean(full),
655
+ eventOnlyShell,
656
+ }));
657
+ if (!opts.quiet) {
658
+ 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})`);
660
+ }
661
+ return {
662
+ affectedChunks: affected,
663
+ seedChunks: seeds,
664
+ full: Boolean(full),
665
+ islandHmr,
666
+ mode,
667
+ eventOnlyShell,
668
+ pageCount: pageCatalog.length,
669
+ };
277
670
  }
278
- const indexChunk = pageCatalog.find((p) => p.chunkId === 'pages/index')?.chunkId || pageCatalog[0].chunkId;
279
- const resumeEntries = await loadPageResumeEntries(distDir, indexChunk);
280
- const styleMeta = await loadDeploymentStyle(distDir);
281
- cssEntry = styleMeta.cssEntry;
282
- styleTheme = styleMeta.styleTheme;
283
- const lazyEventNames = resumeEntries
284
- .filter((e) => isEventStrategy(e.strategy))
285
- .map((e) => e.component)
286
- .filter(Boolean);
287
- const lazySet = new Set(lazyEventNames);
288
- await writeFile(path.join(distDir, 'entry-client.js'), emitEntryClient(componentEntries.filter((e) => !lazySet.has(e.name)), componentEntries.filter((e) => lazySet.has(e.name)), reloadToken), 'utf8');
289
- const strategies = resumeEntries.map((e) => e.strategy);
290
- const eventOnlyShell = isEventOnlyShell(strategies);
291
- await writeFile(path.join(distDir, 'entry-event.js'), emitEntryEvent(reloadToken), 'utf8');
292
- const mode = islandHmr ? 'island' : eventOnlyShell ? 'event-shell' : 'full';
293
- notifySse(JSON.stringify({
294
- type: 'hmr',
295
- mode,
296
- affectedChunks: affected,
297
- seedChunks: seeds,
298
- token: reloadToken,
299
- full: Boolean(full),
300
- eventOnlyShell,
301
- }));
302
- if (!opts.quiet) {
303
- const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
304
- console.log(`vmz serve: soft reload ok (mode=${mode}; pages=${pageCatalog.length}; t=${reloadToken}${aff})`);
671
+ catch (err) {
672
+ reloadToken = prevToken;
673
+ pageCatalog = prevCatalog;
674
+ lastDevError = normalizeDevError(err);
675
+ throw err;
305
676
  }
306
- return {
307
- affectedChunks: affected,
308
- seedChunks: seeds,
309
- full: Boolean(full),
310
- islandHmr,
311
- mode,
312
- eventOnlyShell,
313
- pageCount: pageCatalog.length,
314
- };
315
677
  }
316
678
  /** @param {string} event */
317
679
  function notifySse(event) {
@@ -324,6 +686,184 @@ function notifySse(event) {
324
686
  }
325
687
  }
326
688
  }
689
+ /** @param {unknown} err */
690
+ function normalizeDevError(err) {
691
+ if (err && typeof err === 'object') {
692
+ const e = /** @type {{ message?: string, stack?: string }} */ (err);
693
+ return {
694
+ message: e.message ? String(e.message) : String(err),
695
+ stack: e.stack ? String(e.stack) : undefined,
696
+ at: Date.now(),
697
+ };
698
+ }
699
+ return { message: String(err), at: Date.now() };
700
+ }
701
+ /** @param {{ message: string, stack?: string }} err */
702
+ async function* emitDevErrorHtml(err) {
703
+ const msg = escapeHtml(err.message || 'Unknown error');
704
+ 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>
712
+ body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
713
+ main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
714
+ h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
715
+ pre{white-space:pre-wrap;margin:0 0 1rem}
716
+ .hint{opacity:.65;font-size:12px}
717
+ </style>
718
+ </head>
719
+ <body>
720
+ <main>
721
+ <h1>VMZ Dev Error</h1>
722
+ <pre>${msg}</pre>
723
+ ${stack ? `<pre style="opacity:.7;font-size:12px">${stack}</pre>` : ''}
724
+ <p class="hint">Dev host stayed up. Fix the source and save — soft reload will recover.</p>
725
+ </main>
726
+ <script>
727
+ (() => {
728
+ const es = new EventSource("/__vmz/events");
729
+ es.onmessage = (ev) => {
730
+ let msg = null;
731
+ try { msg = JSON.parse(ev.data); } catch {}
732
+ if (msg && msg.type === "hmr") location.reload();
733
+ };
734
+ })();
735
+ </script>
736
+ </body>
737
+ </html>`;
738
+ }
739
+ /** @param {string} s */
740
+ function escapeHtml(s) {
741
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
742
+ }
743
+ /**
744
+ * Resolve LocaleId from pathname using `_vmz/locale-route-realization.json`.
745
+ * LocaleId is a realization dimension — matching still uses stable route path.
746
+ * @param {string} pathname
747
+ */
748
+ function resolveLocalePath(pathname) {
749
+ const raw = String(pathname || '/');
750
+ const normalized = raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw || '/';
751
+ if (!localeArtifact) {
752
+ return { localeId: 'en', dir: 'ltr', restPath: normalized, redirectTo: null };
753
+ }
754
+ const supported = (localeArtifact.locales || []).map((l) => l.id);
755
+ const defaultLocale = localeArtifact.defaultLocale || supported[0] || 'en';
756
+ const directions = Object.fromEntries((localeArtifact.locales || []).map((l) => [l.id, l.direction || 'ltr']));
757
+ const routing = localeArtifact.routing || {};
758
+ const parts = normalized.split('/').filter(Boolean);
759
+ let localeId = null;
760
+ let restPath = normalized;
761
+ if (parts.length && supported.includes(parts[0])) {
762
+ localeId = parts[0];
763
+ const rest = parts.slice(1);
764
+ restPath = rest.length ? `/${rest.join('/')}` : '/';
765
+ }
766
+ // omit defaultPrefix: prefixed defaultLocale URL redirects to unprefixed canonical.
767
+ if (routing.defaultPrefix === 'omit' && localeId === defaultLocale) {
768
+ return {
769
+ localeId: defaultLocale,
770
+ dir: directions[defaultLocale] || 'ltr',
771
+ restPath,
772
+ redirectTo: restPath,
773
+ };
774
+ }
775
+ const contentLocale = localeId || defaultLocale;
776
+ return {
777
+ localeId: contentLocale,
778
+ dir: directions[contentLocale] || 'ltr',
779
+ restPath,
780
+ redirectTo: null,
781
+ };
782
+ }
783
+ /**
784
+ * Realize href for current LocaleId (prefix strategy). Kept local so serve-host
785
+ * stays free of CLI package imports.
786
+ * @param {string} href
787
+ * @param {string} localeId
788
+ * @param {any} artifact
789
+ */
790
+ function localizeSameAppHrefHost(href, localeId, artifact) {
791
+ if (!href || !localeId || !artifact)
792
+ return href;
793
+ if (href.startsWith('#') || /^(mailto|tel|javascript):/i.test(href))
794
+ return href;
795
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('/'))
796
+ return href;
797
+ let pathname = String(href);
798
+ let search = '';
799
+ let hash = '';
800
+ const hashIdx = pathname.indexOf('#');
801
+ if (hashIdx >= 0) {
802
+ hash = pathname.slice(hashIdx);
803
+ pathname = pathname.slice(0, hashIdx);
804
+ }
805
+ const qIdx = pathname.indexOf('?');
806
+ if (qIdx >= 0) {
807
+ search = pathname.slice(qIdx);
808
+ pathname = pathname.slice(0, qIdx);
809
+ }
810
+ if (!pathname)
811
+ pathname = '/';
812
+ const supported = (artifact.locales || []).map((l) => l.id).filter(Boolean);
813
+ const defaultLocale = artifact.defaultLocale || artifact.routing?.defaultLocale;
814
+ const routing = artifact.routing || {};
815
+ const strategy = routing.strategy || 'prefix';
816
+ const defaultPrefix = routing.defaultPrefix || 'include';
817
+ const parts = pathname.split('/').filter(Boolean);
818
+ let rest = pathname;
819
+ if (parts.length && supported.includes(parts[0])) {
820
+ const r = parts.slice(1);
821
+ rest = r.length ? `/${r.join('/')}` : '/';
822
+ }
823
+ if (rest.length > 1 && rest.endsWith('/'))
824
+ rest = rest.slice(0, -1);
825
+ if (!rest.startsWith('/'))
826
+ rest = `/${rest}`;
827
+ if (strategy === 'none' || strategy === 'domain')
828
+ return `${rest}${search}${hash}`;
829
+ const omitDefault = defaultPrefix === 'omit' && localeId === defaultLocale;
830
+ if (omitDefault)
831
+ return `${rest}${search}${hash}`;
832
+ const pathOut = rest === '/' ? `/${localeId}` : `/${localeId}${rest}`;
833
+ return `${pathOut}${search}${hash}`;
834
+ }
835
+ /**
836
+ * @param {string} html
837
+ * @param {string} localeId
838
+ * @param {any} artifact
839
+ */
840
+ function localizeBodyLinksInHost(html, localeId, artifact) {
841
+ if (!html || !localeId || !artifact)
842
+ return html;
843
+ return String(html).replace(/<a\b([^>]*)>/gi, (full, attrs) => {
844
+ if (!/\bdata-vmz-route\s*=/.test(attrs))
845
+ return full;
846
+ const hm = attrs.match(/\bhref\s*=\s*"([^"]*)"/i);
847
+ if (!hm)
848
+ return full;
849
+ const next = localizeSameAppHrefHost(hm[1], localeId, artifact);
850
+ if (next === hm[1])
851
+ return full;
852
+ const newAttrs = attrs.replace(/\bhref\s*=\s*"[^"]*"/i, `href="${escapeAttr(next)}"`);
853
+ return `<a${newAttrs}>`;
854
+ });
855
+ }
856
+ /**
857
+ * @param {string} chunkId
858
+ * @param {string} localeId
859
+ */
860
+ function pageMetaAlternates(chunkId, localeId) {
861
+ if (!localeArtifact?.pageMetas)
862
+ return [];
863
+ const meta = localeArtifact.pageMetas.find((m) => m.routeId === chunkId && m.locale === localeId) ||
864
+ localeArtifact.pageMetas.find((m) => m.routeId === chunkId && m.locale === localeArtifact.defaultLocale);
865
+ return Array.isArray(meta?.alternates) ? meta.alternates : [];
866
+ }
327
867
  /** @param {string} href */
328
868
  function bustUrl(href) {
329
869
  const u = new URL(href);
@@ -400,6 +940,8 @@ async function listPageClientFiles(dir) {
400
940
  }
401
941
  else if (e.isFile() && e.name.endsWith('.client.js')) {
402
942
  const stem = e.name.replace(/\.client\.js$/, '');
943
+ if (isRouteBoundaryStem(stem))
944
+ continue;
403
945
  const chunkId = ['pages', ...relParts, stem].join('/');
404
946
  out.push({
405
947
  chunkId,
@@ -414,6 +956,7 @@ async function listPageClientFiles(dir) {
414
956
  }
415
957
  /**
416
958
  * File-route segments from chunk id (`pages/Install` → `/install`).
959
+ * Skips URL-invisible `(group)` dirs; boundary stems never reach here.
417
960
  * @param {string} chunkId
418
961
  */
419
962
  function parseChunkSegments(chunkId) {
@@ -423,6 +966,8 @@ function parseChunkSegments(chunkId) {
423
966
  const segs = [];
424
967
  for (let i = 0; i < parts.length; i++) {
425
968
  const p = parts[i];
969
+ if (isRouteGroupDir(p))
970
+ continue;
426
971
  if (p === 'index' && i === parts.length - 1)
427
972
  continue;
428
973
  const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
@@ -436,6 +981,38 @@ function parseChunkSegments(chunkId) {
436
981
  }
437
982
  return segs;
438
983
  }
984
+ function isRouteGroupDir(seg) {
985
+ return typeof seg === 'string' && seg.startsWith('(') && seg.endsWith(')') && seg.length > 2;
986
+ }
987
+ function isRouteBoundaryStem(stem) {
988
+ return stem === 'Layout' || stem === 'Loading' || stem === 'Error' || stem === 'NotFound';
989
+ }
990
+ /**
991
+ * Nearest `Layout.client.js` walking up from the page chunk (outer→inner).
992
+ * @param {string} pageChunkId
993
+ * @returns {string[]}
994
+ */
995
+ function resolveLayoutChain(pageChunkId) {
996
+ const rel = pageChunkId.replace(/^pages\//, '');
997
+ const parts = rel.split('/').filter(Boolean);
998
+ parts.pop(); // page stem
999
+ /** @type {string[]} */
1000
+ const chain = [];
1001
+ for (let i = parts.length; i >= 0; i--) {
1002
+ const dirParts = parts.slice(0, i);
1003
+ const layoutChunk = ['pages', ...dirParts, 'Layout'].join('/');
1004
+ const abs = path.join(distDir, `${layoutChunk}.client.js`);
1005
+ try {
1006
+ // sync existence — layouts are compile artifacts next to pages
1007
+ if (existsSync(abs))
1008
+ chain.unshift(layoutChunk);
1009
+ }
1010
+ catch {
1011
+ /* ignore */
1012
+ }
1013
+ }
1014
+ return chain;
1015
+ }
439
1016
  /**
440
1017
  * @param {string} pathname
441
1018
  * @param {typeof pageCatalog} catalog
@@ -459,6 +1036,35 @@ function matchFileRoute(pathname, catalog) {
459
1036
  }
460
1037
  return best;
461
1038
  }
1039
+ /**
1040
+ * @param {ReturnType<typeof parseChunkSegments>} segs
1041
+ * @param {string} pathname
1042
+ * @returns {Record<string, string>}
1043
+ */
1044
+ function extractRouteParams(segs, pathname) {
1045
+ const pathParts = decodeURIComponent(pathname.split('?')[0] || '/')
1046
+ .replace(/\/+$/, '')
1047
+ .split('/')
1048
+ .filter(Boolean);
1049
+ /** @type {Record<string, string>} */
1050
+ const params = {};
1051
+ let j = 0;
1052
+ for (let i = 0; i < segs.length; i++) {
1053
+ const s = segs[i];
1054
+ if (s.kind === 'catch') {
1055
+ if (s.name)
1056
+ params[s.name] = pathParts.slice(j).join('/');
1057
+ return params;
1058
+ }
1059
+ if (j >= pathParts.length)
1060
+ break;
1061
+ if (s.kind === 'param' && s.name) {
1062
+ params[s.name] = pathParts[j];
1063
+ }
1064
+ j++;
1065
+ }
1066
+ return params;
1067
+ }
462
1068
  /**
463
1069
  * @param {ReturnType<typeof parseChunkSegments>} segs
464
1070
  * @param {string[]} pathParts
@@ -536,9 +1142,10 @@ globalThis.__vmzLoadComponent = async (name) => {
536
1142
  };`
537
1143
  : '';
538
1144
  return `/**
539
- * Generated by vmz serve — hydrate matched file-route page (data-vmz-page).
1145
+ * Generated by vmz serve — hydrate matched file-route page (data-vmz-page) + layout chain + client Link takeover.
540
1146
  */
541
- import { registerComponents, hydrate } from ${JSON.stringify(`./vmz-dom.js${q}`)};
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}`)};
542
1149
  ${imports}
543
1150
 
544
1151
  ${map}
@@ -548,8 +1155,25 @@ const root = document.getElementById("app");
548
1155
  if (!root) throw new Error("vmz: missing #app");
549
1156
  const chunkId = root.getAttribute("data-vmz-page");
550
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
+ }
551
1168
  const Page = (await import("./" + chunkId + ".client.js${q}")).default;
552
- await hydrate(Page, root);
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
+ });
553
1177
  `;
554
1178
  }
555
1179
  /**