@vmz/core 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -1
- package/dist/dom.d.ts +206 -0
- package/dist/dom.js +3067 -0
- package/dist/http.d.ts +6 -0
- package/dist/http.js +27 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/serve-host.d.ts +1 -0
- package/dist/serve-host.js +736 -0
- package/dist/serve-host.mjs +736 -0
- package/dist/server.d.ts +39 -0
- package/dist/server.js +437 -0
- package/package.json +34 -4
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Generic VMZ Node host — SSR file-route pages + dist static + RPC/REST.
|
|
4
|
+
*
|
|
5
|
+
* Invoked by `vmz serve` / `vmz dev` (or: node dist/vmz-serve-host.mjs).
|
|
6
|
+
*
|
|
7
|
+
* Pathname → `pages/**` (PascalCase stem → lowercase URL; `index` → parent;
|
|
8
|
+
* `[Param]` / `[...rest]` dynamic). Not an SPA shell.
|
|
9
|
+
*
|
|
10
|
+
* `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
|
|
11
|
+
* GET `/__vmz/events` SSE notifies the browser:
|
|
12
|
+
* - island HMR → re-import `entry-client.js` (no full document reload)
|
|
13
|
+
* - otherwise → `location.reload()`
|
|
14
|
+
*/
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { readdir, writeFile, readFile } from 'node:fs/promises';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
|
+
import { setServerModuleResolver, setRoutes, handleNodeRequest } from './vmz-runtime.js';
|
|
20
|
+
import { registerComponents, renderToStream } from './vmz-dom.js';
|
|
21
|
+
const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const host = process.env.VMZ_HOST || '127.0.0.1';
|
|
23
|
+
const port = Number(process.env.VMZ_PORT || process.env.PORT || 5173);
|
|
24
|
+
const isDev = process.env.VMZ_DEV === '1' || process.env.VMZ_DEV === 'true';
|
|
25
|
+
/** @type {number} */
|
|
26
|
+
let reloadToken = Date.now();
|
|
27
|
+
/** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
|
|
28
|
+
let pageCatalog = [];
|
|
29
|
+
/** @type {Map<string, any>} */
|
|
30
|
+
const pageCtors = new Map();
|
|
31
|
+
/** Stylesheet from deployment `cssEntry` (e.g. vmz.css). */
|
|
32
|
+
let cssEntry = null;
|
|
33
|
+
/** @type {{ defaultThemeId: string, themeIds: string[], activationAttr: string, contentHash: string|null } | null} */
|
|
34
|
+
let styleTheme = null;
|
|
35
|
+
/** @type {Set<import('node:http').ServerResponse>} */
|
|
36
|
+
const sseClients = new Set();
|
|
37
|
+
setServerModuleResolver((moduleId) => {
|
|
38
|
+
const rel = moduleId.replace(/^#server\//, '') + '.js';
|
|
39
|
+
return bustUrl(pathToFileURL(path.join(distDir, '#server', rel)).href);
|
|
40
|
+
});
|
|
41
|
+
await softReload({ quiet: true });
|
|
42
|
+
/**
|
|
43
|
+
* @param {import('node:http').IncomingMessage} req
|
|
44
|
+
* @returns {Promise<string>}
|
|
45
|
+
*/
|
|
46
|
+
function readRequestBody(req) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
req.on('data', (c) => chunks.push(c));
|
|
50
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
51
|
+
req.on('error', reject);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} pathname
|
|
56
|
+
* @returns {Promise<string | null>}
|
|
57
|
+
*/
|
|
58
|
+
async function renderPage(pathname, opts = {}) {
|
|
59
|
+
const rendered = await renderPageStream(pathname, opts);
|
|
60
|
+
if (!rendered)
|
|
61
|
+
return null;
|
|
62
|
+
const stream = rendered.stream ?? rendered;
|
|
63
|
+
let html = '';
|
|
64
|
+
for await (const chunk of stream) {
|
|
65
|
+
html += chunk;
|
|
66
|
+
}
|
|
67
|
+
return html;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Stream shell + Direct serialize body for the matched file-route page.
|
|
71
|
+
* @param {string} pathname
|
|
72
|
+
* @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
|
|
73
|
+
* @returns {Promise<{ status: number, stream: AsyncGenerator<string, void, void> } | null>}
|
|
74
|
+
*/
|
|
75
|
+
async function renderPageStream(pathname, opts = {}) {
|
|
76
|
+
let match = matchFileRoute(pathname, pageCatalog);
|
|
77
|
+
let status = 200;
|
|
78
|
+
const gated = await runRouteGate(pathname, match?.chunkId);
|
|
79
|
+
if (gated === 'not_found') {
|
|
80
|
+
match = findRootCatchAll(pageCatalog);
|
|
81
|
+
status = 404;
|
|
82
|
+
}
|
|
83
|
+
else if (!match) {
|
|
84
|
+
match = findRootCatchAll(pageCatalog);
|
|
85
|
+
status = 404;
|
|
86
|
+
}
|
|
87
|
+
else if (isRootCatchAll(match)) {
|
|
88
|
+
status = 404;
|
|
89
|
+
}
|
|
90
|
+
if (!match)
|
|
91
|
+
return null;
|
|
92
|
+
const Page = await loadPageCtor(match.chunkId);
|
|
93
|
+
if (!Page)
|
|
94
|
+
return null;
|
|
95
|
+
const resumeEntries = await loadPageResumeEntries(distDir, match.chunkId);
|
|
96
|
+
const strategies = resumeEntries.map((e) => e.strategy);
|
|
97
|
+
const eventOnlyShell = isEventOnlyShell(strategies);
|
|
98
|
+
return {
|
|
99
|
+
status,
|
|
100
|
+
stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, opts),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* @param {any} Page
|
|
105
|
+
* @param {string} chunkId
|
|
106
|
+
* @param {boolean} eventOnlyShell
|
|
107
|
+
* @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
|
|
108
|
+
*/
|
|
109
|
+
async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
|
|
110
|
+
const signal = opts.signal;
|
|
111
|
+
const live = isDev
|
|
112
|
+
? `\n <script>
|
|
113
|
+
(() => {
|
|
114
|
+
const es = new EventSource("/__vmz/events");
|
|
115
|
+
es.onmessage = async (ev) => {
|
|
116
|
+
let msg = null;
|
|
117
|
+
try { msg = JSON.parse(ev.data); } catch { /* plain string */ }
|
|
118
|
+
if (!msg || msg.type !== "hmr") {
|
|
119
|
+
if (ev.data === "reload") location.reload();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (msg.mode === "island") {
|
|
123
|
+
try {
|
|
124
|
+
const { registerComponents, hydrate } = await import("/vmz-dom.js?t=" + msg.token);
|
|
125
|
+
const names = (msg.affectedChunks || [])
|
|
126
|
+
.map((c) => String(c))
|
|
127
|
+
.filter((c) => c.startsWith("components/") || !c.includes("/"))
|
|
128
|
+
.map((c) => c.split("/").pop())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
const map = {};
|
|
131
|
+
for (const name of names) {
|
|
132
|
+
let mod;
|
|
133
|
+
try {
|
|
134
|
+
mod = await import("/components/" + name + ".client.js?t=" + msg.token);
|
|
135
|
+
} catch {
|
|
136
|
+
mod = await import("/" + name + ".client.js?t=" + msg.token);
|
|
137
|
+
}
|
|
138
|
+
map[name] = mod.default;
|
|
139
|
+
}
|
|
140
|
+
if (Object.keys(map).length) registerComponents(map);
|
|
141
|
+
const root = document.getElementById("app");
|
|
142
|
+
const pageChunk = root && root.getAttribute("data-vmz-page");
|
|
143
|
+
if (root && pageChunk) {
|
|
144
|
+
const pageMod = await import("/" + pageChunk + ".client.js?t=" + msg.token);
|
|
145
|
+
await hydrate(pageMod.default, root, {}, { preserveState: true, skipOnMount: true });
|
|
146
|
+
} else {
|
|
147
|
+
location.reload();
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error("vmz island HMR failed", err);
|
|
151
|
+
location.reload();
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
location.reload();
|
|
156
|
+
};
|
|
157
|
+
})();
|
|
158
|
+
</script>`
|
|
159
|
+
: '';
|
|
160
|
+
if (signal?.aborted)
|
|
161
|
+
return;
|
|
162
|
+
const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
|
|
163
|
+
const htmlTheme = htmlThemeAttributeForId(themeId);
|
|
164
|
+
const themeBoot = themeBootstrapScript();
|
|
165
|
+
const cssLink = cssEntry ? ` <link rel="stylesheet" href="/${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}" />\n` : '';
|
|
166
|
+
yield `<!DOCTYPE html>
|
|
167
|
+
<html lang="en"${htmlTheme}>
|
|
168
|
+
<head>
|
|
169
|
+
<meta charset="utf-8" />
|
|
170
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
171
|
+
<title>VMZ</title>
|
|
172
|
+
${themeBoot}${cssLink}</head>
|
|
173
|
+
<body>
|
|
174
|
+
<div id="app" data-vmz-page="${escapeAttr(chunkId)}">`;
|
|
175
|
+
for await (const chunk of renderToStream(Page, {}, { signal })) {
|
|
176
|
+
if (signal?.aborted)
|
|
177
|
+
return;
|
|
178
|
+
yield chunk;
|
|
179
|
+
}
|
|
180
|
+
if (signal?.aborted)
|
|
181
|
+
return;
|
|
182
|
+
yield `</div>
|
|
183
|
+
<script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}
|
|
184
|
+
</body>
|
|
185
|
+
</html>`;
|
|
186
|
+
}
|
|
187
|
+
const server = http.createServer((req, res) => {
|
|
188
|
+
const url = new URL(req.url || '/', `http://${host}:${port}`);
|
|
189
|
+
if (url.pathname === '/__vmz/reload' && req.method === 'POST') {
|
|
190
|
+
readRequestBody(req)
|
|
191
|
+
.then((raw) => {
|
|
192
|
+
let payload = {};
|
|
193
|
+
try {
|
|
194
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
payload = {};
|
|
198
|
+
}
|
|
199
|
+
return softReload({ payload });
|
|
200
|
+
})
|
|
201
|
+
.then((info) => {
|
|
202
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
203
|
+
res.end(JSON.stringify({ ok: true, token: reloadToken, ...info }));
|
|
204
|
+
})
|
|
205
|
+
.catch((err) => {
|
|
206
|
+
console.error('vmz serve: soft reload failed', err);
|
|
207
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
208
|
+
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (url.pathname === '/__vmz/events' && req.method === 'GET') {
|
|
213
|
+
res.writeHead(200, {
|
|
214
|
+
'content-type': 'text/event-stream',
|
|
215
|
+
'cache-control': 'no-cache',
|
|
216
|
+
connection: 'keep-alive',
|
|
217
|
+
});
|
|
218
|
+
res.write(': connected\n\n');
|
|
219
|
+
sseClients.add(res);
|
|
220
|
+
req.on('close', () => {
|
|
221
|
+
sseClients.delete(res);
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
handleNodeRequest(req, res, { distDir, renderPage, renderPageStream });
|
|
226
|
+
});
|
|
227
|
+
server.listen(port, host, () => {
|
|
228
|
+
console.log(`vmz serve http://${host}:${port} (dist=${distDir}${isDev ? ', dev' : ''})`);
|
|
229
|
+
});
|
|
230
|
+
/**
|
|
231
|
+
* Re-import routes / pages / components with a new cache-bust token.
|
|
232
|
+
* Keeps the HTTP server process alive (no Node restart).
|
|
233
|
+
* @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
|
|
234
|
+
*/
|
|
235
|
+
async function softReload(opts = {}) {
|
|
236
|
+
reloadToken = Date.now();
|
|
237
|
+
const affected = opts.payload?.affectedChunks ?? [];
|
|
238
|
+
const seeds = opts.payload?.seedChunks ?? [];
|
|
239
|
+
const full = opts.payload?.full;
|
|
240
|
+
const islandHmr = Boolean(opts.payload?.islandHmr);
|
|
241
|
+
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;
|
|
265
|
+
}
|
|
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);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
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})`);
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
affectedChunks: affected,
|
|
308
|
+
seedChunks: seeds,
|
|
309
|
+
full: Boolean(full),
|
|
310
|
+
islandHmr,
|
|
311
|
+
mode,
|
|
312
|
+
eventOnlyShell,
|
|
313
|
+
pageCount: pageCatalog.length,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** @param {string} event */
|
|
317
|
+
function notifySse(event) {
|
|
318
|
+
for (const client of [...sseClients]) {
|
|
319
|
+
try {
|
|
320
|
+
client.write(`data: ${event}\n\n`);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
sseClients.delete(client);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** @param {string} href */
|
|
328
|
+
function bustUrl(href) {
|
|
329
|
+
const u = new URL(href);
|
|
330
|
+
u.searchParams.set('t', String(reloadToken));
|
|
331
|
+
return u.href;
|
|
332
|
+
}
|
|
333
|
+
/** @param {string} chunkId */
|
|
334
|
+
async function loadPageCtor(chunkId) {
|
|
335
|
+
const pageRel = `${chunkId}.client.js`;
|
|
336
|
+
const href = bustUrl(pathToFileURL(path.join(distDir, pageRel)).href);
|
|
337
|
+
const mod = await import(href);
|
|
338
|
+
pageCtors.set(chunkId, mod.default);
|
|
339
|
+
return mod.default;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* @param {string} dir
|
|
343
|
+
* @returns {Promise<Array<{ name: string, entry: string }>>}
|
|
344
|
+
*/
|
|
345
|
+
async function listClientComponents(dir) {
|
|
346
|
+
/** @type {Map<string, { name: string, entry: string }>} */
|
|
347
|
+
const byName = new Map();
|
|
348
|
+
try {
|
|
349
|
+
const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
|
|
350
|
+
const dep = JSON.parse(raw);
|
|
351
|
+
for (const unit of dep.units || []) {
|
|
352
|
+
if (unit?.kind !== 'component')
|
|
353
|
+
continue;
|
|
354
|
+
const chunkId = String(unit.chunkId || '');
|
|
355
|
+
const name = chunkId.split('/').pop();
|
|
356
|
+
if (!name)
|
|
357
|
+
continue;
|
|
358
|
+
const entry = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
|
|
359
|
+
byName.set(name, { name, entry });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
/* fall through to directory scan */
|
|
364
|
+
}
|
|
365
|
+
if (byName.size === 0) {
|
|
366
|
+
const folder = path.join(dir, 'components');
|
|
367
|
+
let files = [];
|
|
368
|
+
try {
|
|
369
|
+
files = await readdir(folder);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
for (const f of files.filter((name) => name.endsWith('.client.js'))) {
|
|
375
|
+
const name = f.replace(/\.client\.js$/, '');
|
|
376
|
+
byName.set(name, { name, entry: `components/${name}.client.js` });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Discover compiled page modules under dist/pages.
|
|
383
|
+
* @param {string} dir
|
|
384
|
+
*/
|
|
385
|
+
async function listPageClientFiles(dir) {
|
|
386
|
+
const root = path.join(dir, 'pages');
|
|
387
|
+
/** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
|
|
388
|
+
const out = [];
|
|
389
|
+
async function walk(abs, relParts) {
|
|
390
|
+
let ents;
|
|
391
|
+
try {
|
|
392
|
+
ents = await readdir(abs, { withFileTypes: true });
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
for (const e of ents) {
|
|
398
|
+
if (e.isDirectory()) {
|
|
399
|
+
await walk(path.join(abs, e.name), [...relParts, e.name]);
|
|
400
|
+
}
|
|
401
|
+
else if (e.isFile() && e.name.endsWith('.client.js')) {
|
|
402
|
+
const stem = e.name.replace(/\.client\.js$/, '');
|
|
403
|
+
const chunkId = ['pages', ...relParts, stem].join('/');
|
|
404
|
+
out.push({
|
|
405
|
+
chunkId,
|
|
406
|
+
pageRel: `${chunkId}.client.js`,
|
|
407
|
+
segs: parseChunkSegments(chunkId),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
await walk(root, []);
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* File-route segments from chunk id (`pages/Install` → `/install`).
|
|
417
|
+
* @param {string} chunkId
|
|
418
|
+
*/
|
|
419
|
+
function parseChunkSegments(chunkId) {
|
|
420
|
+
const rel = chunkId.replace(/^pages\//, '');
|
|
421
|
+
const parts = rel.split('/').filter(Boolean);
|
|
422
|
+
/** @type {Array<{ kind: 'static' | 'param' | 'catch', value?: string, name?: string }>} */
|
|
423
|
+
const segs = [];
|
|
424
|
+
for (let i = 0; i < parts.length; i++) {
|
|
425
|
+
const p = parts[i];
|
|
426
|
+
if (p === 'index' && i === parts.length - 1)
|
|
427
|
+
continue;
|
|
428
|
+
const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
|
|
429
|
+
const param = /^\[([^\]]+)\]$/.exec(p);
|
|
430
|
+
if (catchAll)
|
|
431
|
+
segs.push({ kind: 'catch', name: catchAll[1] });
|
|
432
|
+
else if (param)
|
|
433
|
+
segs.push({ kind: 'param', name: param[1] });
|
|
434
|
+
else
|
|
435
|
+
segs.push({ kind: 'static', value: p.toLowerCase() });
|
|
436
|
+
}
|
|
437
|
+
return segs;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* @param {string} pathname
|
|
441
|
+
* @param {typeof pageCatalog} catalog
|
|
442
|
+
*/
|
|
443
|
+
function matchFileRoute(pathname, catalog) {
|
|
444
|
+
const pathParts = decodeURIComponent(pathname.split('?')[0] || '/')
|
|
445
|
+
.replace(/\/+$/, '')
|
|
446
|
+
.split('/')
|
|
447
|
+
.filter(Boolean)
|
|
448
|
+
.map((p) => p.toLowerCase());
|
|
449
|
+
let best = null;
|
|
450
|
+
let bestScore = -1;
|
|
451
|
+
for (const page of catalog) {
|
|
452
|
+
const score = scoreRoute(page.segs, pathParts);
|
|
453
|
+
if (score == null)
|
|
454
|
+
continue;
|
|
455
|
+
if (score > bestScore) {
|
|
456
|
+
bestScore = score;
|
|
457
|
+
best = page;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return best;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* @param {ReturnType<typeof parseChunkSegments>} segs
|
|
464
|
+
* @param {string[]} pathParts
|
|
465
|
+
* @returns {number | null}
|
|
466
|
+
*/
|
|
467
|
+
function scoreRoute(segs, pathParts) {
|
|
468
|
+
let i = 0;
|
|
469
|
+
let j = 0;
|
|
470
|
+
let score = 0;
|
|
471
|
+
while (i < segs.length) {
|
|
472
|
+
const s = segs[i];
|
|
473
|
+
if (s.kind === 'catch') {
|
|
474
|
+
// Required catch-all `[...slug]` needs ≥1 remaining segment (not `/`).
|
|
475
|
+
if (j >= pathParts.length)
|
|
476
|
+
return null;
|
|
477
|
+
score += 1;
|
|
478
|
+
return score;
|
|
479
|
+
}
|
|
480
|
+
if (j >= pathParts.length)
|
|
481
|
+
return null;
|
|
482
|
+
if (s.kind === 'static') {
|
|
483
|
+
if (s.value !== pathParts[j])
|
|
484
|
+
return null;
|
|
485
|
+
score += 1000;
|
|
486
|
+
}
|
|
487
|
+
else if (s.kind === 'param') {
|
|
488
|
+
score += 100;
|
|
489
|
+
}
|
|
490
|
+
i++;
|
|
491
|
+
j++;
|
|
492
|
+
}
|
|
493
|
+
if (j !== pathParts.length)
|
|
494
|
+
return null;
|
|
495
|
+
return score + segs.length;
|
|
496
|
+
}
|
|
497
|
+
function isRootCatchAll(page) {
|
|
498
|
+
return page?.segs?.length === 1 && page.segs[0].kind === 'catch';
|
|
499
|
+
}
|
|
500
|
+
function findRootCatchAll(catalog) {
|
|
501
|
+
return catalog.find((p) => isRootCatchAll(p)) || null;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Optional app gate: `dist/vmz-route-gate.mjs` → `{ check(pathname, chunkId) => 'not_found' | null }`.
|
|
505
|
+
* @param {string} pathname
|
|
506
|
+
* @param {string | undefined} chunkId
|
|
507
|
+
*/
|
|
508
|
+
async function runRouteGate(pathname, chunkId) {
|
|
509
|
+
try {
|
|
510
|
+
const href = bustUrl(pathToFileURL(path.join(distDir, 'vmz-route-gate.mjs')).href);
|
|
511
|
+
const mod = await import(href);
|
|
512
|
+
if (typeof mod.check !== 'function')
|
|
513
|
+
return null;
|
|
514
|
+
return await mod.check(pathname, chunkId ?? null);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* @param {Array<{ name: string, entry: string }>} eager
|
|
522
|
+
* @param {Array<{ name: string, entry: string }>} lazy
|
|
523
|
+
* @param {number} token
|
|
524
|
+
*/
|
|
525
|
+
function emitEntryClient(eager, lazy, token) {
|
|
526
|
+
const q = `?t=${token}`;
|
|
527
|
+
const imports = eager.map((e) => `import ${e.name} from ${JSON.stringify(`./${e.entry}${q}`)};`).join('\n');
|
|
528
|
+
const map = eager.length ? `registerComponents({ ${eager.map((e) => e.name).join(', ')} });` : '';
|
|
529
|
+
const entryByName = Object.fromEntries([...eager, ...lazy].map((e) => [e.name, e.entry]));
|
|
530
|
+
const loader = lazy.length
|
|
531
|
+
? `const __vmzComponentEntries = ${JSON.stringify(entryByName)};
|
|
532
|
+
globalThis.__vmzLoadComponent = async (name) => {
|
|
533
|
+
const entry = __vmzComponentEntries[name] || ("components/" + name + ".client.js");
|
|
534
|
+
const mod = await import("./" + entry + "${q}");
|
|
535
|
+
return mod.default;
|
|
536
|
+
};`
|
|
537
|
+
: '';
|
|
538
|
+
return `/**
|
|
539
|
+
* Generated by vmz serve — hydrate matched file-route page (data-vmz-page).
|
|
540
|
+
*/
|
|
541
|
+
import { registerComponents, hydrate } from ${JSON.stringify(`./vmz-dom.js${q}`)};
|
|
542
|
+
${imports}
|
|
543
|
+
|
|
544
|
+
${map}
|
|
545
|
+
${loader}
|
|
546
|
+
|
|
547
|
+
const root = document.getElementById("app");
|
|
548
|
+
if (!root) throw new Error("vmz: missing #app");
|
|
549
|
+
const chunkId = root.getAttribute("data-vmz-page");
|
|
550
|
+
if (!chunkId) throw new Error("vmz: missing data-vmz-page");
|
|
551
|
+
const Page = (await import("./" + chunkId + ".client.js${q}")).default;
|
|
552
|
+
await hydrate(Page, root);
|
|
553
|
+
`;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* EventEntry zero-framework bootstrap: no static import of vmz-dom / page / islands.
|
|
557
|
+
* Framework bytes load only inside the first matching DOM event handler.
|
|
558
|
+
* @param {number} token
|
|
559
|
+
*/
|
|
560
|
+
function emitEntryEvent(token) {
|
|
561
|
+
const q = `?t=${token}`;
|
|
562
|
+
return `/**
|
|
563
|
+
* Generated by vmz serve — EventEntry zero-framework JS shell.
|
|
564
|
+
*/
|
|
565
|
+
(async () => {
|
|
566
|
+
const roots = [...document.querySelectorAll(
|
|
567
|
+
'[data-vmz-entry="event"], [data-vmz-client="event"], [data-vmz-client^="event:"]',
|
|
568
|
+
)];
|
|
569
|
+
for (const el of roots) {
|
|
570
|
+
if (el.__vmzEventWired) continue;
|
|
571
|
+
el.__vmzEventWired = true;
|
|
572
|
+
const strat = el.getAttribute("data-vmz-client") || "event";
|
|
573
|
+
let type = "click";
|
|
574
|
+
if (strat.startsWith("event:") && strat.length > 6) type = strat.slice(6) || "click";
|
|
575
|
+
else if (strat === "click") type = "click";
|
|
576
|
+
el.addEventListener(
|
|
577
|
+
type,
|
|
578
|
+
async () => {
|
|
579
|
+
const { registerComponents, resume } = await import(${JSON.stringify(`./vmz-dom.js${q}`)});
|
|
580
|
+
const name = el.getAttribute("data-vmz-island");
|
|
581
|
+
if (!name) throw new Error("vmz: EventEntry missing data-vmz-island");
|
|
582
|
+
const Comp = (await import("./components/" + name + ".client.js${q}")).default;
|
|
583
|
+
registerComponents({ [name]: Comp });
|
|
584
|
+
await resume(Comp, el);
|
|
585
|
+
},
|
|
586
|
+
{ once: true },
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
})();
|
|
590
|
+
`;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Style Theme cookie / localStorage key (host contract, not a second theme API).
|
|
594
|
+
*/
|
|
595
|
+
const THEME_STORE_KEY = 'vmz-theme';
|
|
596
|
+
/**
|
|
597
|
+
* @param {string} dir
|
|
598
|
+
* @returns {Promise<{ cssEntry: string|null, styleTheme: typeof styleTheme, styleBundleHash: string|null }>}
|
|
599
|
+
*/
|
|
600
|
+
async function loadDeploymentStyle(dir) {
|
|
601
|
+
try {
|
|
602
|
+
const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
|
|
603
|
+
const dep = JSON.parse(raw);
|
|
604
|
+
const entry = dep.cssEntry;
|
|
605
|
+
const css = typeof entry === 'string' && entry.trim() ? entry.trim().replace(/^\/+/, '') : null;
|
|
606
|
+
const st = dep.styleTheme;
|
|
607
|
+
let theme = null;
|
|
608
|
+
if (st && typeof st === 'object') {
|
|
609
|
+
theme = {
|
|
610
|
+
defaultThemeId: String(st.defaultThemeId || 'default'),
|
|
611
|
+
themeIds: Array.isArray(st.themeIds) ? st.themeIds.map(String) : [],
|
|
612
|
+
activationAttr: String(st.activationAttr || 'data-theme'),
|
|
613
|
+
prefersColorScheme: st.prefersColorScheme && typeof st.prefersColorScheme === 'object'
|
|
614
|
+
? Object.fromEntries(Object.entries(st.prefersColorScheme).map(([k, v]) => [String(k), String(v)]))
|
|
615
|
+
: {},
|
|
616
|
+
contentHash: st.contentHash ? String(st.contentHash) : null,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
const bundleHash = typeof dep.styleBundleHash === 'string' && dep.styleBundleHash.trim() ? dep.styleBundleHash.trim() : null;
|
|
620
|
+
return { cssEntry: css, styleTheme: theme, styleBundleHash: bundleHash };
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return { cssEntry: null, styleTheme: null, styleBundleHash: null };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Priority: `?theme=` → cookie → none (CSS `:root` + prefers-color-scheme media).
|
|
628
|
+
* Explicit ids (including default) always win over OS preference via activation attr.
|
|
629
|
+
* @param {URLSearchParams | undefined} searchParams
|
|
630
|
+
* @param {string | undefined} cookieHeader
|
|
631
|
+
* @returns {string|null}
|
|
632
|
+
*/
|
|
633
|
+
function resolveThemeId(searchParams, cookieHeader) {
|
|
634
|
+
if (!styleTheme)
|
|
635
|
+
return null;
|
|
636
|
+
const ids = styleTheme.themeIds || [];
|
|
637
|
+
const q = searchParams && typeof searchParams.get === 'function' ? searchParams.get('theme') : null;
|
|
638
|
+
if (q && ids.includes(q))
|
|
639
|
+
return q;
|
|
640
|
+
const fromCookie = readCookie(cookieHeader, THEME_STORE_KEY);
|
|
641
|
+
if (fromCookie && ids.includes(fromCookie))
|
|
642
|
+
return fromCookie;
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Always emit activation attr for an explicit theme id (incl. default) so it overrides OS media.
|
|
647
|
+
* @param {string|null} themeId
|
|
648
|
+
*/
|
|
649
|
+
function htmlThemeAttributeForId(themeId) {
|
|
650
|
+
if (!styleTheme || !themeId)
|
|
651
|
+
return '';
|
|
652
|
+
const attr = styleTheme.activationAttr || 'data-theme';
|
|
653
|
+
if (!(styleTheme.themeIds || []).includes(themeId))
|
|
654
|
+
return '';
|
|
655
|
+
return ` ${attr}="${escapeAttr(themeId)}"`;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Inline boot when SSR had no query/cookie: apply explicit `localStorage` only.
|
|
659
|
+
* No stored choice → leave bare `<html>` so CSS `@media (prefers-color-scheme)` follows OS live.
|
|
660
|
+
* Explicit ids (incl. default) always set the activation attr so they override OS media.
|
|
661
|
+
*/
|
|
662
|
+
function themeBootstrapScript() {
|
|
663
|
+
if (!styleTheme)
|
|
664
|
+
return '';
|
|
665
|
+
const attr = JSON.stringify(styleTheme.activationAttr || 'data-theme');
|
|
666
|
+
const ids = JSON.stringify(styleTheme.themeIds || []);
|
|
667
|
+
const key = JSON.stringify(THEME_STORE_KEY);
|
|
668
|
+
return ` <script>(function(){try{var k=${key},attr=${attr},ids=${ids};var id=localStorage.getItem(k);if(!id||ids.indexOf(id)<0)return;document.documentElement.setAttribute(attr,id);}catch(e){}})();</script>\n`;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* @param {string|undefined} header
|
|
672
|
+
* @param {string} name
|
|
673
|
+
*/
|
|
674
|
+
function readCookie(header, name) {
|
|
675
|
+
if (!header)
|
|
676
|
+
return null;
|
|
677
|
+
const parts = String(header).split(';');
|
|
678
|
+
for (const part of parts) {
|
|
679
|
+
const idx = part.indexOf('=');
|
|
680
|
+
if (idx < 0)
|
|
681
|
+
continue;
|
|
682
|
+
const k = part.slice(0, idx).trim();
|
|
683
|
+
if (k !== name)
|
|
684
|
+
continue;
|
|
685
|
+
try {
|
|
686
|
+
return decodeURIComponent(part.slice(idx + 1).trim());
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
return part.slice(idx + 1).trim();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* @param {string} dir
|
|
696
|
+
* @returns {Promise<string|null>}
|
|
697
|
+
*/
|
|
698
|
+
async function loadCssEntry(dir) {
|
|
699
|
+
const meta = await loadDeploymentStyle(dir);
|
|
700
|
+
return meta.cssEntry;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* @param {string} dir
|
|
704
|
+
* @param {string} chunkId
|
|
705
|
+
* @returns {Promise<Array<{ component: string, strategy: string }>>}
|
|
706
|
+
*/
|
|
707
|
+
async function loadPageResumeEntries(dir, chunkId) {
|
|
708
|
+
try {
|
|
709
|
+
const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
|
|
710
|
+
const dep = JSON.parse(raw);
|
|
711
|
+
const units = Array.isArray(dep.units) ? dep.units : [];
|
|
712
|
+
const page = units.find((u) => u.chunkId === chunkId) || units.find((u) => u.chunkId === 'pages/index') || units.find((u) => u.kind === 'page');
|
|
713
|
+
const entries = Array.isArray(page?.resumeEntries) ? page.resumeEntries : [];
|
|
714
|
+
return entries.map((e) => ({
|
|
715
|
+
component: String(e.component || ''),
|
|
716
|
+
strategy: String(e.strategy || ''),
|
|
717
|
+
}));
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** @param {string} strategy */
|
|
724
|
+
function isEventStrategy(strategy) {
|
|
725
|
+
return strategy === 'event' || strategy === 'click' || strategy.startsWith('event:');
|
|
726
|
+
}
|
|
727
|
+
/** @param {string[]} strategies */
|
|
728
|
+
function isEventOnlyShell(strategies) {
|
|
729
|
+
if (!strategies.length)
|
|
730
|
+
return false;
|
|
731
|
+
return strategies.every((s) => isEventStrategy(s));
|
|
732
|
+
}
|
|
733
|
+
/** @param {string} s */
|
|
734
|
+
function escapeAttr(s) {
|
|
735
|
+
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
736
|
+
}
|