@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.
- package/README.md +4 -2
- package/dist/client-nav.d.ts +46 -0
- package/dist/client-nav.js +186 -0
- package/dist/dom.d.ts +28 -12
- package/dist/dom.js +1540 -226
- package/dist/serve-host.mjs +546 -90
- package/dist/server.d.ts +1 -2
- package/dist/server.js +103 -18
- package/package.json +6 -2
- package/dist/serve-host.js +0 -736
package/dist/serve-host.mjs
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
|
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) => ({"&":"&","<":"<",">":">"}[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
|
-
|
|
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
|
-
|
|
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 {"<":"<",">":">","&":"&"}[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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
|
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
|
/**
|