@vmz/core 0.0.2 → 0.0.4

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.
@@ -9,15 +9,16 @@
9
9
  *
10
10
  * `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
11
11
  * GET `/__vmz/events` SSE notifies the browser:
12
- * - island HMR → re-import `entry-client.js` (no full document reload)
13
- * - otherwise → `location.reload()`
12
+ * - island HMR → re-import `entry-client.js` (no full document reload)
13
+ * - otherwise → `location.reload`
14
14
  */
15
15
  import http from 'node:http';
16
16
  import path from 'node:path';
17
17
  import { readdir, writeFile, readFile } from 'node:fs/promises';
18
+ import { existsSync } from 'node:fs';
18
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
20
  import { setServerModuleResolver, setRoutes, handleNodeRequest } from './vmz-runtime.js';
20
- import { registerComponents, renderToStream } from './vmz-dom.js';
21
+ import { registerComponents, renderToStream, renderToString } from './vmz-dom.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);
@@ -34,11 +35,26 @@ let cssEntry = null;
34
35
  let styleTheme = null;
35
36
  /** @type {Set<import('node:http').ServerResponse>} */
36
37
  const sseClients = new Set();
38
+ /** In-flight HTTP requests (graceful shutdown drain). */
39
+ let inFlight = 0;
40
+ /** When true, refuse new work except health. */
41
+ let shuttingDown = false;
42
+ let ready = false;
43
+ /** @type {{ message: string, stack?: string, at: number } | null} */
44
+ let lastDevError = null;
37
45
  setServerModuleResolver((moduleId) => {
38
46
  const rel = moduleId.replace(/^#server\//, '') + '.js';
39
47
  return bustUrl(pathToFileURL(path.join(distDir, '#server', rel)).href);
40
48
  });
41
- await softReload({ quiet: true });
49
+ try {
50
+ await softReload({ quiet: true });
51
+ ready = true;
52
+ }
53
+ catch (err) {
54
+ lastDevError = normalizeDevError(err);
55
+ ready = true; // still accept HTTP — serve error page / recover on next reload
56
+ console.error('vmz serve: initial load failed (dev host stays up)', lastDevError.message);
57
+ }
42
58
  /**
43
59
  * @param {import('node:http').IncomingMessage} req
44
60
  * @returns {Promise<string>}
@@ -68,11 +84,16 @@ async function renderPage(pathname, opts = {}) {
68
84
  }
69
85
  /**
70
86
  * Stream shell + Direct serialize body for the matched file-route page.
87
+ * Runs Page.access (closed allow/redirect/not-found/deny) before load;
88
+ * POST may run Page.action before re-render.
71
89
  * @param {string} pathname
72
- * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
73
- * @returns {Promise<{ status: number, stream: AsyncGenerator<string, void, void> } | null>}
90
+ * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string, method?: string, body?: unknown }} [opts]
91
+ * @returns {Promise<{ status: number, stream?: AsyncGenerator<string, void, void>, redirect?: string, headers?: Record<string, string> } | null>}
74
92
  */
75
93
  async function renderPageStream(pathname, opts = {}) {
94
+ if (isDev && lastDevError && pageCtors.size === 0) {
95
+ return { status: 500, stream: emitDevErrorHtml(lastDevError) };
96
+ }
76
97
  let match = matchFileRoute(pathname, pageCatalog);
77
98
  let status = 200;
78
99
  const gated = await runRouteGate(pathname, match?.chunkId);
@@ -87,38 +108,218 @@ async function renderPageStream(pathname, opts = {}) {
87
108
  else if (isRootCatchAll(match)) {
88
109
  status = 404;
89
110
  }
90
- if (!match)
111
+ if (!match) {
112
+ if (isDev && lastDevError) {
113
+ return { status: 500, stream: emitDevErrorHtml(lastDevError) };
114
+ }
91
115
  return null;
116
+ }
92
117
  const Page = await loadPageCtor(match.chunkId);
93
- if (!Page)
118
+ if (!Page) {
119
+ if (isDev && lastDevError) {
120
+ return { status: 500, stream: emitDevErrorHtml(lastDevError) };
121
+ }
94
122
  return null;
123
+ }
124
+ const params = extractRouteParams(match.segs, pathname);
125
+ const method = String(opts.method || 'GET').toUpperCase();
126
+ if (typeof Page.access === 'function') {
127
+ const access = await Page.access({
128
+ params,
129
+ pathname,
130
+ chunkId: match.chunkId,
131
+ signal: opts.signal,
132
+ searchParams: opts.searchParams,
133
+ method,
134
+ });
135
+ const closed = normalizeAccessResult(access);
136
+ if (closed.kind === 'redirect') {
137
+ return { status: 302, redirect: closed.location, headers: { Location: closed.location } };
138
+ }
139
+ if (closed.kind === 'deny') {
140
+ return { status: 403, stream: emitAccessShell('route-access-deny') };
141
+ }
142
+ if (closed.kind === 'not-found') {
143
+ const catchAll = findRootCatchAll(pageCatalog);
144
+ if (catchAll) {
145
+ const NotFound = await loadPageCtor(catchAll.chunkId);
146
+ if (NotFound) {
147
+ const resumeEntries = await loadPageResumeEntries(distDir, catchAll.chunkId);
148
+ const eventOnlyShell = isEventOnlyShell(resumeEntries.map((e) => e.strategy));
149
+ return {
150
+ status: 404,
151
+ stream: emitPageHtml(NotFound, catchAll.chunkId, eventOnlyShell, { ...params }, opts),
152
+ };
153
+ }
154
+ }
155
+ return { status: 404, stream: emitAccessShell('route-access-not-found') };
156
+ }
157
+ }
158
+ let props = { ...params };
159
+ if (method === 'POST' && typeof Page.action === 'function') {
160
+ const acted = await Page.action({
161
+ params,
162
+ pathname,
163
+ chunkId: match.chunkId,
164
+ signal: opts.signal,
165
+ searchParams: opts.searchParams,
166
+ body: opts.body,
167
+ method,
168
+ });
169
+ const actionClosed = normalizeActionResult(acted);
170
+ if (actionClosed.kind === 'redirect') {
171
+ return { status: 302, redirect: actionClosed.location, headers: { Location: actionClosed.location } };
172
+ }
173
+ if (actionClosed.kind === 'deny') {
174
+ return { status: 403, stream: emitAccessShell('route-action-deny') };
175
+ }
176
+ if (actionClosed.kind === 'not-found') {
177
+ return { status: 404, stream: emitAccessShell('route-action-not-found') };
178
+ }
179
+ if (actionClosed.props) {
180
+ props = { ...props, ...actionClosed.props };
181
+ }
182
+ }
183
+ if (typeof Page.load === 'function') {
184
+ const loaded = await Page.load({
185
+ params,
186
+ pathname,
187
+ chunkId: match.chunkId,
188
+ signal: opts.signal,
189
+ searchParams: opts.searchParams,
190
+ });
191
+ if (opts.signal?.aborted) {
192
+ return { status: 499, stream: emitAccessShell('route-nav-cancelled') };
193
+ }
194
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) {
195
+ props = { ...props, ...loaded };
196
+ }
197
+ }
198
+ if (opts.signal?.aborted) {
199
+ return { status: 499, stream: emitAccessShell('route-nav-cancelled') };
200
+ }
95
201
  const resumeEntries = await loadPageResumeEntries(distDir, match.chunkId);
96
202
  const strategies = resumeEntries.map((e) => e.strategy);
97
203
  const eventOnlyShell = isEventOnlyShell(strategies);
204
+ const layoutChain = resolveLayoutChain(match.chunkId);
98
205
  return {
99
206
  status,
100
- stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, opts),
207
+ stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, props, opts, layoutChain),
101
208
  };
102
209
  }
210
+ /**
211
+ * @param {unknown} access
212
+ * @returns {{ kind: 'allow' } | { kind: 'redirect', location: string } | { kind: 'deny' } | { kind: 'not-found' }}
213
+ */
214
+ function normalizeAccessResult(access) {
215
+ if (access == null || access === true)
216
+ return { kind: 'allow' };
217
+ if (typeof access === 'string') {
218
+ const k = access.toLowerCase();
219
+ if (k === 'allow')
220
+ return { kind: 'allow' };
221
+ if (k === 'deny')
222
+ return { kind: 'deny' };
223
+ if (k === 'not-found' || k === 'notfound')
224
+ return { kind: 'not-found' };
225
+ }
226
+ if (typeof access === 'object') {
227
+ const kind = String(access.kind || access.type || 'allow').toLowerCase();
228
+ if (kind === 'redirect') {
229
+ const location = String(access.location || access.to || access.href || '');
230
+ if (!location)
231
+ return { kind: 'deny' };
232
+ return { kind: 'redirect', location };
233
+ }
234
+ if (kind === 'deny')
235
+ return { kind: 'deny' };
236
+ if (kind === 'not-found' || kind === 'notfound')
237
+ return { kind: 'not-found' };
238
+ return { kind: 'allow' };
239
+ }
240
+ return { kind: 'allow' };
241
+ }
242
+ /**
243
+ * @param {unknown} acted
244
+ * @returns {{ kind: 'allow', props?: Record<string, unknown> } | { kind: 'redirect', location: string } | { kind: 'deny' } | { kind: 'not-found' }}
245
+ */
246
+ function normalizeActionResult(acted) {
247
+ const base = normalizeAccessResult(acted);
248
+ if (base.kind !== 'allow')
249
+ return base;
250
+ if (acted && typeof acted === 'object' && acted.props && typeof acted.props === 'object') {
251
+ return { kind: 'allow', props: acted.props };
252
+ }
253
+ if (acted && typeof acted === 'object' && !('kind' in acted) && !('type' in acted) && !Array.isArray(acted)) {
254
+ return { kind: 'allow', props: acted };
255
+ }
256
+ return { kind: 'allow' };
257
+ }
258
+ /**
259
+ * Minimal HTML for closed access/action results when no NotFound page exists.
260
+ * @param {string} marker
261
+ */
262
+ async function* emitAccessShell(marker) {
263
+ yield `<!DOCTYPE html>
264
+ <html lang="en">
265
+ <head><meta charset="utf-8" /><title>VMZ</title></head>
266
+ <body><p>${marker}</p></body>
267
+ </html>`;
268
+ }
103
269
  /**
104
270
  * @param {any} Page
105
271
  * @param {string} chunkId
106
272
  * @param {boolean} eventOnlyShell
273
+ * @param {Record<string, unknown>} props
107
274
  * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
275
+ * @param {string[]} [layoutChain] layout chunk ids outer→inner
108
276
  */
109
- async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
277
+ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {}, layoutChain = []) {
110
278
  const signal = opts.signal;
111
279
  const live = isDev
112
280
  ? `\n <script>
113
281
  (() => {
114
282
  const es = new EventSource("/__vmz/events");
283
+ function showOverlay(err) {
284
+ let el = document.getElementById("vmz-dev-overlay");
285
+ if (!el) {
286
+ el = document.createElement("div");
287
+ el.id = "vmz-dev-overlay";
288
+ el.setAttribute("role", "alert");
289
+ Object.assign(el.style, {
290
+ position: "fixed", inset: "0", zIndex: "2147483646",
291
+ background: "rgba(15,17,21,0.92)", color: "#f4f4f5",
292
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
293
+ padding: "2rem", overflow: "auto",
294
+ });
295
+ document.documentElement.appendChild(el);
296
+ }
297
+ const msg = (err && err.message) || String(err || "Unknown error");
298
+ const stack = (err && err.stack) || "";
299
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
300
+ el.innerHTML = "<div style=\\"max-width:56rem;margin:0 auto\\">"
301
+ + "<p style=\\"margin:0 0 .5rem;color:#f87171;font-weight:700\\">VMZ Dev Error</p>"
302
+ + "<pre style=\\"white-space:pre-wrap;margin:0 0 1rem;font-size:13px;line-height:1.45\\">" + esc(msg) + "</pre>"
303
+ + (stack ? "<pre style=\\"white-space:pre-wrap;opacity:.7;font-size:12px\\">" + esc(stack) + "</pre>" : "")
304
+ + "<p style=\\"opacity:.65;font-size:12px\\">Fix the file and save — soft reload will clear this overlay.</p>"
305
+ + "</div>";
306
+ }
307
+ function hideOverlay() {
308
+ const el = document.getElementById("vmz-dev-overlay");
309
+ if (el) el.remove();
310
+ }
115
311
  es.onmessage = async (ev) => {
116
312
  let msg = null;
117
313
  try { msg = JSON.parse(ev.data); } catch { /* plain string */ }
314
+ if (msg && msg.type === "error") {
315
+ showOverlay(msg);
316
+ return;
317
+ }
118
318
  if (!msg || msg.type !== "hmr") {
119
319
  if (ev.data === "reload") location.reload();
120
320
  return;
121
321
  }
322
+ hideOverlay();
122
323
  if (msg.mode === "island") {
123
324
  try {
124
325
  const { registerComponents, hydrate } = await import("/vmz-dom.js?t=" + msg.token);
@@ -142,13 +343,18 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
142
343
  const pageChunk = root && root.getAttribute("data-vmz-page");
143
344
  if (root && pageChunk) {
144
345
  const pageMod = await import("/" + pageChunk + ".client.js?t=" + msg.token);
145
- await hydrate(pageMod.default, root, {}, { preserveState: true, skipOnMount: true });
346
+ let hmrProps = {};
347
+ try {
348
+ const raw = root.getAttribute("data-vmz-props");
349
+ if (raw) hmrProps = JSON.parse(raw);
350
+ } catch { /* ignore */ }
351
+ await hydrate(pageMod.default, root, hmrProps, { preserveState: true, skipOnMount: true });
146
352
  } else {
147
353
  location.reload();
148
354
  }
149
355
  } catch (err) {
150
356
  console.error("vmz island HMR failed", err);
151
- location.reload();
357
+ showOverlay({ message: String(err && err.message || err), stack: err && err.stack });
152
358
  }
153
359
  return;
154
360
  }
@@ -157,12 +363,24 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
157
363
  })();
158
364
  </script>`
159
365
  : '';
366
+ const bootOverlay = isDev && lastDevError
367
+ ? `\n <script>window.__VMZ_DEV_ERROR__=${JSON.stringify(lastDevError)};` +
368
+ `(function(){var e=window.__VMZ_DEV_ERROR__;if(!e)return;` +
369
+ `var ev=new Event("message");ev.data=JSON.stringify({type:"error",message:e.message,stack:e.stack});` +
370
+ `/* paint immediately */` +
371
+ `var d=document.createElement("div");d.id="vmz-dev-overlay";d.setAttribute("role","alert");` +
372
+ `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"});` +
373
+ `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>";` +
374
+ `document.documentElement.appendChild(d);})();</script>`
375
+ : '';
160
376
  if (signal?.aborted)
161
377
  return;
162
378
  const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
163
379
  const htmlTheme = htmlThemeAttributeForId(themeId);
164
380
  const themeBoot = themeBootstrapScript();
165
381
  const cssLink = cssEntry ? ` <link rel="stylesheet" href="/${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}" />\n` : '';
382
+ const propsJson = JSON.stringify(props ?? {});
383
+ const layoutAttr = layoutChain.length ? ` data-vmz-layout="${escapeAttr(layoutChain.join(','))}"` : '';
166
384
  yield `<!DOCTYPE html>
167
385
  <html lang="en"${htmlTheme}>
168
386
  <head>
@@ -171,21 +389,64 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
171
389
  <title>VMZ</title>
172
390
  ${themeBoot}${cssLink}</head>
173
391
  <body>
174
- <div id="app" data-vmz-page="${escapeAttr(chunkId)}">`;
175
- for await (const chunk of renderToStream(Page, {}, { signal })) {
392
+ <div id="app" data-vmz-page="${escapeAttr(chunkId)}"${layoutAttr} data-vmz-props="${escapeAttr(propsJson)}">`;
393
+ let bodyHtml = '';
394
+ for await (const chunk of renderToStream(Page, props, { signal })) {
176
395
  if (signal?.aborted)
177
396
  return;
178
- yield chunk;
397
+ bodyHtml += chunk;
179
398
  }
399
+ if (signal?.aborted)
400
+ return;
401
+ // Wrap page HTML in layout chain (outer → inner) via default slot injection.
402
+ for (let i = layoutChain.length - 1; i >= 0; i--) {
403
+ const Layout = await loadPageCtor(layoutChain[i]);
404
+ if (!Layout)
405
+ continue;
406
+ bodyHtml = await renderToString(Layout, {}, { signal, slotHtml: bodyHtml });
407
+ if (signal?.aborted)
408
+ return;
409
+ }
410
+ yield bodyHtml;
180
411
  if (signal?.aborted)
181
412
  return;
182
413
  yield `</div>
183
- <script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}
414
+ <script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}${bootOverlay}
184
415
  </body>
185
416
  </html>`;
186
417
  }
187
418
  const server = http.createServer((req, res) => {
188
419
  const url = new URL(req.url || '/', `http://${host}:${port}`);
420
+ if (url.pathname === '/__vmz/health' && req.method === 'GET') {
421
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
422
+ res.end(JSON.stringify({ status: 'ok', shuttingDown, inFlight }));
423
+ return;
424
+ }
425
+ if (url.pathname === '/__vmz/ready' && req.method === 'GET') {
426
+ if (!ready || shuttingDown) {
427
+ res.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
428
+ res.end(JSON.stringify({ status: 'not-ready', ready, shuttingDown }));
429
+ return;
430
+ }
431
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
432
+ res.end(JSON.stringify({ status: 'ready', ready: true, inFlight }));
433
+ return;
434
+ }
435
+ if (shuttingDown) {
436
+ res.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
437
+ res.end(JSON.stringify({ status: 'shutting-down' }));
438
+ return;
439
+ }
440
+ inFlight += 1;
441
+ let settled = false;
442
+ const done = () => {
443
+ if (settled)
444
+ return;
445
+ settled = true;
446
+ inFlight = Math.max(0, inFlight - 1);
447
+ };
448
+ res.on('finish', done);
449
+ res.on('close', done);
189
450
  if (url.pathname === '/__vmz/reload' && req.method === 'POST') {
190
451
  readRequestBody(req)
191
452
  .then((raw) => {
@@ -204,8 +465,15 @@ const server = http.createServer((req, res) => {
204
465
  })
205
466
  .catch((err) => {
206
467
  console.error('vmz serve: soft reload failed', err);
468
+ lastDevError = normalizeDevError(err);
469
+ notifySse(JSON.stringify({
470
+ type: 'error',
471
+ message: lastDevError.message,
472
+ stack: lastDevError.stack,
473
+ at: lastDevError.at,
474
+ }));
207
475
  res.writeHead(500, { 'content-type': 'application/json' });
208
- res.end(JSON.stringify({ ok: false, error: String(err) }));
476
+ res.end(JSON.stringify({ ok: false, error: lastDevError.message }));
209
477
  });
210
478
  return;
211
479
  }
@@ -227,91 +495,142 @@ const server = http.createServer((req, res) => {
227
495
  server.listen(port, host, () => {
228
496
  console.log(`vmz serve http://${host}:${port} (dist=${distDir}${isDev ? ', dev' : ''})`);
229
497
  });
498
+ const SHUTDOWN_TIMEOUT_MS = Number(process.env.VMZ_SHUTDOWN_TIMEOUT_MS || 10000);
499
+ async function gracefulShutdown(signal) {
500
+ if (shuttingDown)
501
+ return;
502
+ shuttingDown = true;
503
+ ready = false;
504
+ console.log(`vmz serve: ${signal} — draining in-flight=${inFlight} timeout=${SHUTDOWN_TIMEOUT_MS}ms`);
505
+ server.close();
506
+ const start = Date.now();
507
+ while (inFlight > 0 && Date.now() - start < SHUTDOWN_TIMEOUT_MS) {
508
+ await new Promise((r) => setTimeout(r, 25));
509
+ }
510
+ for (const client of sseClients) {
511
+ try {
512
+ client.end();
513
+ }
514
+ catch {
515
+ /* ignore */
516
+ }
517
+ }
518
+ sseClients.clear();
519
+ process.exit(inFlight > 0 ? 1 : 0);
520
+ }
521
+ process.on('SIGTERM', () => {
522
+ void gracefulShutdown('SIGTERM');
523
+ });
524
+ process.on('SIGINT', () => {
525
+ void gracefulShutdown('SIGINT');
526
+ });
230
527
  /**
231
528
  * Re-import routes / pages / components with a new cache-bust token.
232
529
  * Keeps the HTTP server process alive (no Node restart).
530
+ * Failed reloads keep the previous in-memory modules (Vite-like resilience).
233
531
  * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
234
532
  */
235
533
  async function softReload(opts = {}) {
236
- reloadToken = Date.now();
534
+ const prevToken = reloadToken;
535
+ const prevCatalog = pageCatalog;
536
+ const nextToken = Date.now();
537
+ reloadToken = nextToken;
237
538
  const affected = opts.payload?.affectedChunks ?? [];
238
539
  const seeds = opts.payload?.seedChunks ?? [];
239
540
  const full = opts.payload?.full;
240
541
  const islandHmr = Boolean(opts.payload?.islandHmr);
241
542
  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;
543
+ try {
544
+ const routes = JSON.parse(await readFile(path.join(distDir, 'vmz-routes.json'), 'utf8'));
545
+ setRoutes(routes);
265
546
  }
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);
547
+ catch {
548
+ setRoutes([]);
549
+ }
550
+ const componentEntries = await listClientComponents(distDir);
551
+ const nextCatalog = await listPageClientFiles(distDir);
552
+ if (!nextCatalog.length) {
553
+ throw new Error(`vmz serve: no pages/**/*.client.js in ${distDir}`);
276
554
  }
555
+ /** @type {Record<string, any>} */
556
+ const components = {};
557
+ /** @type {Map<string, any>} */
558
+ const nextCtors = new Map();
559
+ const affectedNames = new Set(affected
560
+ .map((c) => String(c))
561
+ .filter((c) => c.startsWith('components/') || !c.includes('/'))
562
+ .map((c) => c.split('/').pop())
563
+ .filter(Boolean));
564
+ for (const entry of componentEntries) {
565
+ if (islandHmr && affectedNames.size > 0 && !affectedNames.has(entry.name)) {
566
+ continue;
567
+ }
568
+ const href = bustUrl(pathToFileURL(path.join(distDir, entry.entry)).href);
569
+ const mod = await import(href);
570
+ components[entry.name] = mod.default;
571
+ }
572
+ if (!islandHmr) {
573
+ for (const p of nextCatalog) {
574
+ const pageRel = `${p.chunkId}.client.js`;
575
+ const href = bustUrl(pathToFileURL(path.join(distDir, pageRel)).href);
576
+ const mod = await import(href);
577
+ nextCtors.set(p.chunkId, mod.default);
578
+ }
579
+ }
580
+ pageCatalog = nextCatalog;
581
+ if (!islandHmr) {
582
+ pageCtors.clear();
583
+ for (const [k, v] of nextCtors)
584
+ pageCtors.set(k, v);
585
+ }
586
+ if (Object.keys(components).length) {
587
+ registerComponents(components);
588
+ }
589
+ const indexChunk = pageCatalog.find((p) => p.chunkId === 'pages/index')?.chunkId || pageCatalog[0].chunkId;
590
+ const resumeEntries = await loadPageResumeEntries(distDir, indexChunk);
591
+ const styleMeta = await loadDeploymentStyle(distDir);
592
+ cssEntry = styleMeta.cssEntry;
593
+ styleTheme = styleMeta.styleTheme;
594
+ const lazyEventNames = resumeEntries
595
+ .filter((e) => isEventStrategy(e.strategy))
596
+ .map((e) => e.component)
597
+ .filter(Boolean);
598
+ const lazySet = new Set(lazyEventNames);
599
+ 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');
600
+ const strategies = resumeEntries.map((e) => e.strategy);
601
+ const eventOnlyShell = isEventOnlyShell(strategies);
602
+ await writeFile(path.join(distDir, 'entry-event.js'), emitEntryEvent(reloadToken), 'utf8');
603
+ lastDevError = null;
604
+ const mode = islandHmr ? 'island' : eventOnlyShell ? 'event-shell' : 'full';
605
+ notifySse(JSON.stringify({
606
+ type: 'hmr',
607
+ mode,
608
+ affectedChunks: affected,
609
+ seedChunks: seeds,
610
+ token: reloadToken,
611
+ full: Boolean(full),
612
+ eventOnlyShell,
613
+ }));
614
+ if (!opts.quiet) {
615
+ const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
616
+ console.log(`vmz serve: soft reload ok (mode=${mode}; pages=${pageCatalog.length}; t=${reloadToken}${aff})`);
617
+ }
618
+ return {
619
+ affectedChunks: affected,
620
+ seedChunks: seeds,
621
+ full: Boolean(full),
622
+ islandHmr,
623
+ mode,
624
+ eventOnlyShell,
625
+ pageCount: pageCatalog.length,
626
+ };
277
627
  }
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})`);
628
+ catch (err) {
629
+ reloadToken = prevToken;
630
+ pageCatalog = prevCatalog;
631
+ lastDevError = normalizeDevError(err);
632
+ throw err;
305
633
  }
306
- return {
307
- affectedChunks: affected,
308
- seedChunks: seeds,
309
- full: Boolean(full),
310
- islandHmr,
311
- mode,
312
- eventOnlyShell,
313
- pageCount: pageCatalog.length,
314
- };
315
634
  }
316
635
  /** @param {string} event */
317
636
  function notifySse(event) {
@@ -324,6 +643,60 @@ function notifySse(event) {
324
643
  }
325
644
  }
326
645
  }
646
+ /** @param {unknown} err */
647
+ function normalizeDevError(err) {
648
+ if (err && typeof err === 'object') {
649
+ const e = /** @type {{ message?: string, stack?: string }} */ (err);
650
+ return {
651
+ message: e.message ? String(e.message) : String(err),
652
+ stack: e.stack ? String(e.stack) : undefined,
653
+ at: Date.now(),
654
+ };
655
+ }
656
+ return { message: String(err), at: Date.now() };
657
+ }
658
+ /** @param {{ message: string, stack?: string }} err */
659
+ async function* emitDevErrorHtml(err) {
660
+ const msg = escapeHtml(err.message || 'Unknown error');
661
+ const stack = err.stack ? escapeHtml(err.stack) : '';
662
+ yield `<!DOCTYPE html>
663
+ <html lang="en">
664
+ <head>
665
+ <meta charset="utf-8" />
666
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
667
+ <title>VMZ Dev Error</title>
668
+ <style>
669
+ body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
670
+ main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
671
+ h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
672
+ pre{white-space:pre-wrap;margin:0 0 1rem}
673
+ .hint{opacity:.65;font-size:12px}
674
+ </style>
675
+ </head>
676
+ <body>
677
+ <main>
678
+ <h1>VMZ Dev Error</h1>
679
+ <pre>${msg}</pre>
680
+ ${stack ? `<pre style="opacity:.7;font-size:12px">${stack}</pre>` : ''}
681
+ <p class="hint">Dev host stayed up. Fix the source and save — soft reload will recover.</p>
682
+ </main>
683
+ <script>
684
+ (() => {
685
+ const es = new EventSource("/__vmz/events");
686
+ es.onmessage = (ev) => {
687
+ let msg = null;
688
+ try { msg = JSON.parse(ev.data); } catch {}
689
+ if (msg && msg.type === "hmr") location.reload();
690
+ };
691
+ })();
692
+ </script>
693
+ </body>
694
+ </html>`;
695
+ }
696
+ /** @param {string} s */
697
+ function escapeHtml(s) {
698
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
699
+ }
327
700
  /** @param {string} href */
328
701
  function bustUrl(href) {
329
702
  const u = new URL(href);
@@ -400,6 +773,8 @@ async function listPageClientFiles(dir) {
400
773
  }
401
774
  else if (e.isFile() && e.name.endsWith('.client.js')) {
402
775
  const stem = e.name.replace(/\.client\.js$/, '');
776
+ if (isRouteBoundaryStem(stem))
777
+ continue;
403
778
  const chunkId = ['pages', ...relParts, stem].join('/');
404
779
  out.push({
405
780
  chunkId,
@@ -414,6 +789,7 @@ async function listPageClientFiles(dir) {
414
789
  }
415
790
  /**
416
791
  * File-route segments from chunk id (`pages/Install` → `/install`).
792
+ * Skips URL-invisible `(group)` dirs; boundary stems never reach here.
417
793
  * @param {string} chunkId
418
794
  */
419
795
  function parseChunkSegments(chunkId) {
@@ -423,6 +799,8 @@ function parseChunkSegments(chunkId) {
423
799
  const segs = [];
424
800
  for (let i = 0; i < parts.length; i++) {
425
801
  const p = parts[i];
802
+ if (isRouteGroupDir(p))
803
+ continue;
426
804
  if (p === 'index' && i === parts.length - 1)
427
805
  continue;
428
806
  const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
@@ -436,6 +814,38 @@ function parseChunkSegments(chunkId) {
436
814
  }
437
815
  return segs;
438
816
  }
817
+ function isRouteGroupDir(seg) {
818
+ return typeof seg === 'string' && seg.startsWith('(') && seg.endsWith(')') && seg.length > 2;
819
+ }
820
+ function isRouteBoundaryStem(stem) {
821
+ return stem === 'Layout' || stem === 'Loading' || stem === 'Error' || stem === 'NotFound';
822
+ }
823
+ /**
824
+ * Nearest `Layout.client.js` walking up from the page chunk (outer→inner).
825
+ * @param {string} pageChunkId
826
+ * @returns {string[]}
827
+ */
828
+ function resolveLayoutChain(pageChunkId) {
829
+ const rel = pageChunkId.replace(/^pages\//, '');
830
+ const parts = rel.split('/').filter(Boolean);
831
+ parts.pop(); // page stem
832
+ /** @type {string[]} */
833
+ const chain = [];
834
+ for (let i = parts.length; i >= 0; i--) {
835
+ const dirParts = parts.slice(0, i);
836
+ const layoutChunk = ['pages', ...dirParts, 'Layout'].join('/');
837
+ const abs = path.join(distDir, `${layoutChunk}.client.js`);
838
+ try {
839
+ // sync existence — layouts are compile artifacts next to pages
840
+ if (existsSync(abs))
841
+ chain.unshift(layoutChunk);
842
+ }
843
+ catch {
844
+ /* ignore */
845
+ }
846
+ }
847
+ return chain;
848
+ }
439
849
  /**
440
850
  * @param {string} pathname
441
851
  * @param {typeof pageCatalog} catalog
@@ -459,6 +869,35 @@ function matchFileRoute(pathname, catalog) {
459
869
  }
460
870
  return best;
461
871
  }
872
+ /**
873
+ * @param {ReturnType<typeof parseChunkSegments>} segs
874
+ * @param {string} pathname
875
+ * @returns {Record<string, string>}
876
+ */
877
+ function extractRouteParams(segs, pathname) {
878
+ const pathParts = decodeURIComponent(pathname.split('?')[0] || '/')
879
+ .replace(/\/+$/, '')
880
+ .split('/')
881
+ .filter(Boolean);
882
+ /** @type {Record<string, string>} */
883
+ const params = {};
884
+ let j = 0;
885
+ for (let i = 0; i < segs.length; i++) {
886
+ const s = segs[i];
887
+ if (s.kind === 'catch') {
888
+ if (s.name)
889
+ params[s.name] = pathParts.slice(j).join('/');
890
+ return params;
891
+ }
892
+ if (j >= pathParts.length)
893
+ break;
894
+ if (s.kind === 'param' && s.name) {
895
+ params[s.name] = pathParts[j];
896
+ }
897
+ j++;
898
+ }
899
+ return params;
900
+ }
462
901
  /**
463
902
  * @param {ReturnType<typeof parseChunkSegments>} segs
464
903
  * @param {string[]} pathParts
@@ -536,9 +975,10 @@ globalThis.__vmzLoadComponent = async (name) => {
536
975
  };`
537
976
  : '';
538
977
  return `/**
539
- * Generated by vmz serve — hydrate matched file-route page (data-vmz-page).
978
+ * Generated by vmz serve — hydrate matched file-route page (data-vmz-page) + layout chain + client Link takeover.
540
979
  */
541
- import { registerComponents, hydrate } from ${JSON.stringify(`./vmz-dom.js${q}`)};
980
+ import { registerComponents, hydrate, hydrateRoute, destroy } from ${JSON.stringify(`./vmz-dom.js${q}`)};
981
+ import { installClientNavigation } from ${JSON.stringify(`./vmz-client-nav.js${q}`)};
542
982
  ${imports}
543
983
 
544
984
  ${map}
@@ -548,8 +988,24 @@ const root = document.getElementById("app");
548
988
  if (!root) throw new Error("vmz: missing #app");
549
989
  const chunkId = root.getAttribute("data-vmz-page");
550
990
  if (!chunkId) throw new Error("vmz: missing data-vmz-page");
991
+ let props = {};
992
+ try {
993
+ const raw = root.getAttribute("data-vmz-props");
994
+ if (raw) props = JSON.parse(raw);
995
+ } catch { /* ignore */ }
996
+ const layoutChain = (root.getAttribute("data-vmz-layout") || "").split(",").map((s) => s.trim()).filter(Boolean);
997
+ const layoutCtors = [];
998
+ for (const id of layoutChain) {
999
+ layoutCtors.push((await import("./" + id + ".client.js${q}")).default);
1000
+ }
551
1001
  const Page = (await import("./" + chunkId + ".client.js${q}")).default;
552
- await hydrate(Page, root);
1002
+ await hydrateRoute(Page, root, props, layoutCtors);
1003
+ installClientNavigation({
1004
+ hydrate,
1005
+ hydrateRoute,
1006
+ destroy,
1007
+ importPage: async (id) => (await import("./" + id + ".client.js${q}")).default,
1008
+ });
553
1009
  `;
554
1010
  }
555
1011
  /**