@zenithbuild/cli 0.4.10 → 0.5.0-beta.2.12

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.
@@ -0,0 +1,655 @@
1
+ // ---------------------------------------------------------------------------
2
+ // preview.js — Zenith CLI V0
3
+ // ---------------------------------------------------------------------------
4
+ // Preview server with manifest-driven route resolution.
5
+ //
6
+ // - Serves /dist assets directly.
7
+ // - Resolves static and dynamic page routes via router-manifest.json.
8
+ // - Executes non-prerender <script server> blocks per request and injects
9
+ // serialized SSR payload via an inline script (`window.__zenith_ssr_data`).
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { createServer } from 'node:http';
14
+ import { access, readFile } from 'node:fs/promises';
15
+ import { extname, join, normalize, resolve, sep, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import {
18
+ compareRouteSpecificity,
19
+ matchRoute as matchManifestRoute,
20
+ resolveRequestRoute
21
+ } from './server/resolve-request-route.js';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+
25
+ const MIME_TYPES = {
26
+ '.html': 'text/html',
27
+ '.js': 'application/javascript',
28
+ '.css': 'text/css',
29
+ '.json': 'application/json',
30
+ '.png': 'image/png',
31
+ '.jpg': 'image/jpeg',
32
+ '.svg': 'image/svg+xml'
33
+ };
34
+
35
+ const SERVER_SCRIPT_RUNNER = String.raw`
36
+ import vm from 'node:vm';
37
+ import fs from 'node:fs/promises';
38
+ import path from 'node:path';
39
+ import { pathToFileURL, fileURLToPath } from 'node:url';
40
+
41
+ const source = process.env.ZENITH_SERVER_SOURCE || '';
42
+ const sourcePath = process.env.ZENITH_SERVER_SOURCE_PATH || '';
43
+ const params = JSON.parse(process.env.ZENITH_SERVER_PARAMS || '{}');
44
+ const requestUrl = process.env.ZENITH_SERVER_REQUEST_URL || 'http://localhost/';
45
+ const requestMethod = String(process.env.ZENITH_SERVER_REQUEST_METHOD || 'GET').toUpperCase();
46
+ const requestHeaders = JSON.parse(process.env.ZENITH_SERVER_REQUEST_HEADERS || '{}');
47
+ const routePattern = process.env.ZENITH_SERVER_ROUTE_PATTERN || '';
48
+ const routeFile = process.env.ZENITH_SERVER_ROUTE_FILE || sourcePath || '';
49
+ const routeId = process.env.ZENITH_SERVER_ROUTE_ID || routePattern || '';
50
+
51
+ if (!source.trim()) {
52
+ process.stdout.write('null');
53
+ process.exit(0);
54
+ }
55
+
56
+ let cachedTypeScript = undefined;
57
+ async function loadTypeScript() {
58
+ if (cachedTypeScript !== undefined) {
59
+ return cachedTypeScript;
60
+ }
61
+ try {
62
+ const mod = await import('typescript');
63
+ cachedTypeScript = mod.default || mod;
64
+ } catch {
65
+ cachedTypeScript = null;
66
+ }
67
+ return cachedTypeScript;
68
+ }
69
+
70
+ async function transpileIfNeeded(filename, code) {
71
+ const lower = String(filename || '').toLowerCase();
72
+ const isTs =
73
+ lower.endsWith('.ts') ||
74
+ lower.endsWith('.tsx') ||
75
+ lower.endsWith('.mts') ||
76
+ lower.endsWith('.cts');
77
+ if (!isTs) {
78
+ return code;
79
+ }
80
+ const ts = await loadTypeScript();
81
+ if (!ts || typeof ts.transpileModule !== 'function') {
82
+ throw new Error('[zenith-preview] TypeScript is required to execute server modules that import .ts files');
83
+ }
84
+ const output = ts.transpileModule(code, {
85
+ fileName: filename || 'server-script.ts',
86
+ compilerOptions: {
87
+ target: ts.ScriptTarget.ES2022,
88
+ module: ts.ModuleKind.ESNext,
89
+ moduleResolution: ts.ModuleResolutionKind.NodeNext
90
+ },
91
+ reportDiagnostics: false
92
+ });
93
+ return output.outputText;
94
+ }
95
+
96
+ async function exists(filePath) {
97
+ try {
98
+ await fs.access(filePath);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ async function resolveRelativeSpecifier(specifier, parentIdentifier) {
106
+ let basePath = sourcePath;
107
+ if (parentIdentifier && parentIdentifier.startsWith('file:')) {
108
+ basePath = fileURLToPath(parentIdentifier);
109
+ }
110
+
111
+ const baseDir = basePath ? path.dirname(basePath) : process.cwd();
112
+ const candidateBase = specifier.startsWith('file:')
113
+ ? fileURLToPath(specifier)
114
+ : path.resolve(baseDir, specifier);
115
+
116
+ const candidates = [];
117
+ if (path.extname(candidateBase)) {
118
+ candidates.push(candidateBase);
119
+ } else {
120
+ candidates.push(candidateBase);
121
+ candidates.push(candidateBase + '.ts');
122
+ candidates.push(candidateBase + '.tsx');
123
+ candidates.push(candidateBase + '.mts');
124
+ candidates.push(candidateBase + '.cts');
125
+ candidates.push(candidateBase + '.js');
126
+ candidates.push(candidateBase + '.mjs');
127
+ candidates.push(candidateBase + '.cjs');
128
+ candidates.push(path.join(candidateBase, 'index.ts'));
129
+ candidates.push(path.join(candidateBase, 'index.tsx'));
130
+ candidates.push(path.join(candidateBase, 'index.mts'));
131
+ candidates.push(path.join(candidateBase, 'index.cts'));
132
+ candidates.push(path.join(candidateBase, 'index.js'));
133
+ candidates.push(path.join(candidateBase, 'index.mjs'));
134
+ candidates.push(path.join(candidateBase, 'index.cjs'));
135
+ }
136
+
137
+ for (const candidate of candidates) {
138
+ if (await exists(candidate)) {
139
+ return pathToFileURL(candidate).href;
140
+ }
141
+ }
142
+
143
+ throw new Error(
144
+ '[zenith-preview] Cannot resolve server import "' + specifier + '" from "' + (basePath || '<inline>') + '"'
145
+ );
146
+ }
147
+
148
+ function isRelativeSpecifier(specifier) {
149
+ return (
150
+ specifier.startsWith('./') ||
151
+ specifier.startsWith('../') ||
152
+ specifier.startsWith('/') ||
153
+ specifier.startsWith('file:')
154
+ );
155
+ }
156
+
157
+ const safeRequestHeaders =
158
+ requestHeaders && typeof requestHeaders === 'object'
159
+ ? { ...requestHeaders }
160
+ : {};
161
+ const requestSnapshot = new Request(requestUrl, {
162
+ method: requestMethod,
163
+ headers: new Headers(safeRequestHeaders)
164
+ });
165
+ const routeParams = { ...params };
166
+ const routeMeta = {
167
+ id: routeId,
168
+ pattern: routePattern,
169
+ file: routeFile ? path.relative(process.cwd(), routeFile) : ''
170
+ };
171
+
172
+ const context = vm.createContext({
173
+ params: routeParams,
174
+ ctx: {
175
+ params: routeParams,
176
+ url: new URL(requestUrl),
177
+ request: requestSnapshot,
178
+ route: routeMeta
179
+ },
180
+ fetch: globalThis.fetch,
181
+ Headers: globalThis.Headers,
182
+ Request: globalThis.Request,
183
+ Response: globalThis.Response,
184
+ URL,
185
+ URLSearchParams,
186
+ Buffer,
187
+ console,
188
+ process,
189
+ setTimeout,
190
+ clearTimeout,
191
+ setInterval,
192
+ clearInterval
193
+ });
194
+
195
+ const moduleCache = new Map();
196
+ const syntheticModuleCache = new Map();
197
+
198
+ async function createSyntheticModule(specifier) {
199
+ if (syntheticModuleCache.has(specifier)) {
200
+ return syntheticModuleCache.get(specifier);
201
+ }
202
+
203
+ const ns = await import(specifier);
204
+ const exportNames = Object.keys(ns);
205
+ const module = new vm.SyntheticModule(
206
+ exportNames,
207
+ function() {
208
+ for (const key of exportNames) {
209
+ this.setExport(key, ns[key]);
210
+ }
211
+ },
212
+ { context }
213
+ );
214
+ await module.link(() => {
215
+ throw new Error(
216
+ '[zenith-preview] synthetic modules cannot contain nested imports: ' + specifier
217
+ );
218
+ });
219
+ syntheticModuleCache.set(specifier, module);
220
+ return module;
221
+ }
222
+
223
+ async function loadFileModule(moduleUrl) {
224
+ if (moduleCache.has(moduleUrl)) {
225
+ return moduleCache.get(moduleUrl);
226
+ }
227
+
228
+ const filename = fileURLToPath(moduleUrl);
229
+ let code = await fs.readFile(filename, 'utf8');
230
+ code = await transpileIfNeeded(filename, code);
231
+ const module = new vm.SourceTextModule(code, {
232
+ context,
233
+ identifier: moduleUrl,
234
+ initializeImportMeta(meta) {
235
+ meta.url = moduleUrl;
236
+ }
237
+ });
238
+
239
+ moduleCache.set(moduleUrl, module);
240
+ await module.link((specifier, referencingModule) => {
241
+ return linkModule(specifier, referencingModule.identifier);
242
+ });
243
+ return module;
244
+ }
245
+
246
+ async function linkModule(specifier, parentIdentifier) {
247
+ if (!isRelativeSpecifier(specifier)) {
248
+ return createSyntheticModule(specifier);
249
+ }
250
+ const resolvedUrl = await resolveRelativeSpecifier(specifier, parentIdentifier);
251
+ return loadFileModule(resolvedUrl);
252
+ }
253
+
254
+ const allowed = new Set(['data', 'load', 'ssr_data', 'props', 'ssr', 'prerender']);
255
+ const prelude = "const params = globalThis.params;\n" +
256
+ "const ctx = globalThis.ctx;\n" +
257
+ "import { resolveServerPayload } from 'zenith:server-contract';\n" +
258
+ "globalThis.resolveServerPayload = resolveServerPayload;\n";
259
+ const entryIdentifier = sourcePath
260
+ ? pathToFileURL(sourcePath).href
261
+ : 'zenith:server-script';
262
+ const entryTranspileFilename = sourcePath && sourcePath.toLowerCase().endsWith('.zen')
263
+ ? sourcePath.replace(/\.zen$/i, '.ts')
264
+ : (sourcePath || 'server-script.ts');
265
+
266
+ const entryCode = await transpileIfNeeded(entryTranspileFilename, prelude + source);
267
+ const entryModule = new vm.SourceTextModule(entryCode, {
268
+ context,
269
+ identifier: entryIdentifier,
270
+ initializeImportMeta(meta) {
271
+ meta.url = entryIdentifier;
272
+ }
273
+ });
274
+
275
+ moduleCache.set(entryIdentifier, entryModule);
276
+ await entryModule.link((specifier, referencingModule) => {
277
+ if (specifier === 'zenith:server-contract') {
278
+ const defaultPath = path.join(process.cwd(), 'node_modules', '@zenithbuild', 'cli', 'src', 'server-contract.js');
279
+ const contractUrl = pathToFileURL(process.env.ZENITH_SERVER_CONTRACT_PATH || defaultPath).href;
280
+ return loadFileModule(contractUrl).catch(() =>
281
+ loadFileModule(pathToFileURL(defaultPath).href)
282
+ );
283
+ }
284
+ return linkModule(specifier, referencingModule.identifier);
285
+ });
286
+ await entryModule.evaluate();
287
+
288
+ const namespaceKeys = Object.keys(entryModule.namespace);
289
+ for (const key of namespaceKeys) {
290
+ if (!allowed.has(key)) {
291
+ throw new Error('[zenith-preview] unsupported server export "' + key + '"');
292
+ }
293
+ }
294
+
295
+ const exported = entryModule.namespace;
296
+ try {
297
+ const payload = await context.resolveServerPayload({
298
+ exports: exported,
299
+ ctx: context.ctx,
300
+ filePath: sourcePath || 'server_script'
301
+ });
302
+
303
+ process.stdout.write(JSON.stringify(payload === undefined ? null : payload));
304
+ } catch (error) {
305
+ const message = error instanceof Error ? error.message : String(error);
306
+ process.stdout.write(
307
+ JSON.stringify({
308
+ __zenith_error: {
309
+ status: 500,
310
+ code: 'LOAD_FAILED',
311
+ message
312
+ }
313
+ })
314
+ );
315
+ }
316
+ `;
317
+
318
+ /**
319
+ * Create and start a preview server.
320
+ *
321
+ * @param {{ distDir: string, port?: number }} options
322
+ * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
323
+ */
324
+ export async function createPreviewServer(options) {
325
+ const { distDir, port = 4000 } = options;
326
+
327
+ const server = createServer(async (req, res) => {
328
+ const url = new URL(req.url, `http://localhost:${port}`);
329
+
330
+ try {
331
+ if (extname(url.pathname)) {
332
+ const staticPath = resolveWithinDist(distDir, url.pathname);
333
+ if (!staticPath || !(await fileExists(staticPath))) {
334
+ throw new Error('not found');
335
+ }
336
+ const content = await readFile(staticPath);
337
+ const mime = MIME_TYPES[extname(staticPath)] || 'application/octet-stream';
338
+ res.writeHead(200, { 'Content-Type': mime });
339
+ res.end(content);
340
+ return;
341
+ }
342
+
343
+ const routes = await loadRouteManifest(distDir);
344
+ const resolved = resolveRequestRoute(url, routes);
345
+ let htmlPath = null;
346
+
347
+ if (resolved.matched && resolved.route) {
348
+ console.log(`[zenith] Request: ${url.pathname} | Route: ${resolved.route.path} | Params: ${JSON.stringify(resolved.params)}`);
349
+ const output = resolved.route.output.startsWith('/')
350
+ ? resolved.route.output.slice(1)
351
+ : resolved.route.output;
352
+ htmlPath = resolveWithinDist(distDir, output);
353
+ } else {
354
+ htmlPath = toStaticFilePath(distDir, url.pathname);
355
+ }
356
+
357
+ if (!htmlPath || !(await fileExists(htmlPath))) {
358
+ throw new Error('not found');
359
+ }
360
+
361
+ let html = await readFile(htmlPath, 'utf8');
362
+ if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
363
+ let payload = null;
364
+ try {
365
+ payload = await executeServerScript({
366
+ source: resolved.route.server_script,
367
+ sourcePath: resolved.route.server_script_path || '',
368
+ params: resolved.params,
369
+ requestUrl: url.toString(),
370
+ requestMethod: req.method || 'GET',
371
+ requestHeaders: req.headers,
372
+ routePattern: resolved.route.path,
373
+ routeFile: resolved.route.server_script_path || '',
374
+ routeId: routeIdFromSourcePath(resolved.route.server_script_path || '')
375
+ });
376
+ } catch (error) {
377
+ payload = {
378
+ __zenith_error: {
379
+ status: 500,
380
+ code: 'LOAD_FAILED',
381
+ message: error instanceof Error ? error.message : String(error)
382
+ }
383
+ };
384
+ }
385
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
386
+ html = injectSsrPayload(html, payload);
387
+ }
388
+ }
389
+
390
+ res.writeHead(200, { 'Content-Type': 'text/html' });
391
+ res.end(html);
392
+ } catch {
393
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
394
+ res.end('404 Not Found');
395
+ }
396
+ });
397
+
398
+ return new Promise((resolveServer) => {
399
+ server.listen(port, () => {
400
+ const actualPort = server.address().port;
401
+ resolveServer({
402
+ server,
403
+ port: actualPort,
404
+ close: () => {
405
+ server.close();
406
+ }
407
+ });
408
+ });
409
+ });
410
+ }
411
+
412
+ /**
413
+ * @typedef {{
414
+ * path: string;
415
+ * output: string;
416
+ * server_script?: string | null;
417
+ * server_script_path?: string | null;
418
+ * prerender?: boolean;
419
+ * }} PreviewRoute
420
+ */
421
+
422
+ /**
423
+ * @param {string} distDir
424
+ * @returns {Promise<PreviewRoute[]>}
425
+ */
426
+ export async function loadRouteManifest(distDir) {
427
+ const manifestPath = join(distDir, 'assets', 'router-manifest.json');
428
+ try {
429
+ const source = await readFile(manifestPath, 'utf8');
430
+ const parsed = JSON.parse(source);
431
+ const routes = Array.isArray(parsed?.routes) ? parsed.routes : [];
432
+ return routes
433
+ .filter((entry) =>
434
+ entry &&
435
+ typeof entry === 'object' &&
436
+ typeof entry.path === 'string' &&
437
+ typeof entry.output === 'string'
438
+ )
439
+ .sort((a, b) => compareRouteSpecificity(a.path, b.path));
440
+ } catch {
441
+ return [];
442
+ }
443
+ }
444
+
445
+ export const matchRoute = matchManifestRoute;
446
+
447
+ /**
448
+ * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl?: string, requestMethod?: string, requestHeaders?: Record<string, string | string[] | undefined>, routePattern?: string, routeFile?: string, routeId?: string }} input
449
+ * @returns {Promise<Record<string, unknown> | null>}
450
+ */
451
+ export async function executeServerScript(input) {
452
+ const payload = await spawnNodeServerRunner({
453
+ source: input.source,
454
+ sourcePath: input.sourcePath,
455
+ params: input.params,
456
+ requestUrl: input.requestUrl || 'http://localhost/',
457
+ requestMethod: input.requestMethod || 'GET',
458
+ requestHeaders: sanitizeRequestHeaders(input.requestHeaders || {}),
459
+ routePattern: input.routePattern || '',
460
+ routeFile: input.routeFile || input.sourcePath || '',
461
+ routeId: input.routeId || routeIdFromSourcePath(input.sourcePath || '')
462
+ });
463
+
464
+ if (payload === null || payload === undefined) {
465
+ return null;
466
+ }
467
+ if (typeof payload !== 'object' || Array.isArray(payload)) {
468
+ throw new Error('[zenith-preview] server script payload must be an object');
469
+ }
470
+ return payload;
471
+ }
472
+
473
+ /**
474
+ * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl: string, requestMethod: string, requestHeaders: Record<string, string>, routePattern: string, routeFile: string, routeId: string }} input
475
+ * @returns {Promise<unknown>}
476
+ */
477
+ function spawnNodeServerRunner(input) {
478
+ return new Promise((resolvePromise, rejectPromise) => {
479
+ const child = spawn(
480
+ process.execPath,
481
+ ['--experimental-vm-modules', '--input-type=module', '-e', SERVER_SCRIPT_RUNNER],
482
+ {
483
+ env: {
484
+ ...process.env,
485
+ ZENITH_SERVER_SOURCE: input.source,
486
+ ZENITH_SERVER_SOURCE_PATH: input.sourcePath || '',
487
+ ZENITH_SERVER_PARAMS: JSON.stringify(input.params || {}),
488
+ ZENITH_SERVER_REQUEST_URL: input.requestUrl || 'http://localhost/',
489
+ ZENITH_SERVER_REQUEST_METHOD: input.requestMethod || 'GET',
490
+ ZENITH_SERVER_REQUEST_HEADERS: JSON.stringify(input.requestHeaders || {}),
491
+ ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
492
+ ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
493
+ ZENITH_SERVER_ROUTE_ID: input.routeId || '',
494
+ ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
495
+ },
496
+ stdio: ['ignore', 'pipe', 'pipe']
497
+ }
498
+ );
499
+
500
+ let stdout = '';
501
+ let stderr = '';
502
+ child.stdout.on('data', (chunk) => {
503
+ stdout += String(chunk);
504
+ });
505
+ child.stderr.on('data', (chunk) => {
506
+ stderr += String(chunk);
507
+ });
508
+ child.on('error', (error) => {
509
+ rejectPromise(error);
510
+ });
511
+ child.on('close', (code) => {
512
+ if (code !== 0) {
513
+ rejectPromise(
514
+ new Error(
515
+ `[zenith-preview] server script execution failed (${code}): ${stderr.trim() || stdout.trim()}`
516
+ )
517
+ );
518
+ return;
519
+ }
520
+ const raw = stdout.trim();
521
+ if (!raw || raw === 'null') {
522
+ resolvePromise(null);
523
+ return;
524
+ }
525
+ try {
526
+ resolvePromise(JSON.parse(raw));
527
+ } catch (error) {
528
+ rejectPromise(
529
+ new Error(
530
+ `[zenith-preview] invalid server payload JSON: ${error instanceof Error ? error.message : String(error)
531
+ }`
532
+ )
533
+ );
534
+ }
535
+ });
536
+ });
537
+ }
538
+
539
+ /**
540
+ * @param {string} html
541
+ * @param {Record<string, unknown>} payload
542
+ * @returns {string}
543
+ */
544
+ export function injectSsrPayload(html, payload) {
545
+ const serialized = serializeInlineScriptJson(payload);
546
+ const scriptTag = `<script id="zenith-ssr-data">window.__zenith_ssr_data = ${serialized};</script>`;
547
+ const existingTagRe = /<script\b[^>]*\bid=(["'])zenith-ssr-data\1[^>]*>[\s\S]*?<\/script>/i;
548
+ if (existingTagRe.test(html)) {
549
+ return html.replace(existingTagRe, scriptTag);
550
+ }
551
+
552
+ const headClose = html.match(/<\/head>/i);
553
+ if (headClose) {
554
+ return html.replace(/<\/head>/i, `${scriptTag}</head>`);
555
+ }
556
+
557
+ const bodyOpen = html.match(/<body\b[^>]*>/i);
558
+ if (bodyOpen) {
559
+ return html.replace(bodyOpen[0], `${bodyOpen[0]}${scriptTag}`);
560
+ }
561
+
562
+ return `${scriptTag}${html}`;
563
+ }
564
+
565
+ /**
566
+ * @param {Record<string, unknown>} payload
567
+ * @returns {string}
568
+ */
569
+ function serializeInlineScriptJson(payload) {
570
+ return JSON.stringify(payload)
571
+ .replace(/</g, '\\u003C')
572
+ .replace(/>/g, '\\u003E')
573
+ .replace(/\//g, '\\u002F')
574
+ .replace(/\u2028/g, '\\u2028')
575
+ .replace(/\u2029/g, '\\u2029');
576
+ }
577
+
578
+ export function toStaticFilePath(distDir, pathname) {
579
+ let resolved = pathname;
580
+ if (resolved === '/') {
581
+ resolved = '/index.html';
582
+ } else if (!extname(resolved)) {
583
+ resolved += '/index.html';
584
+ }
585
+ return resolveWithinDist(distDir, resolved);
586
+ }
587
+
588
+ export function resolveWithinDist(distDir, requestPath) {
589
+ let decoded = requestPath;
590
+ try {
591
+ decoded = decodeURIComponent(requestPath);
592
+ } catch {
593
+ return null;
594
+ }
595
+
596
+ const normalized = normalize(decoded).replace(/\\/g, '/');
597
+ const relative = normalized.replace(/^\/+/, '');
598
+ const root = resolve(distDir);
599
+ const candidate = resolve(root, relative);
600
+ if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
601
+ return candidate;
602
+ }
603
+ return null;
604
+ }
605
+
606
+ /**
607
+ * @param {Record<string, string | string[] | undefined>} headers
608
+ * @returns {Record<string, string>}
609
+ */
610
+ function sanitizeRequestHeaders(headers) {
611
+ const out = Object.create(null);
612
+ const denyExact = new Set(['authorization', 'cookie', 'proxy-authorization', 'set-cookie']);
613
+ const denyPrefixes = ['x-forwarded-', 'cf-'];
614
+ for (const [rawKey, rawValue] of Object.entries(headers || {})) {
615
+ const key = String(rawKey || '').toLowerCase();
616
+ if (!key) continue;
617
+ if (denyExact.has(key)) continue;
618
+ if (denyPrefixes.some((prefix) => key.startsWith(prefix))) continue;
619
+ let value = '';
620
+ if (Array.isArray(rawValue)) {
621
+ value = rawValue.filter((entry) => entry !== undefined).map(String).join(', ');
622
+ } else if (rawValue !== undefined) {
623
+ value = String(rawValue);
624
+ }
625
+ out[key] = value;
626
+ }
627
+ return out;
628
+ }
629
+
630
+ /**
631
+ * @param {string} sourcePath
632
+ * @returns {string}
633
+ */
634
+ function routeIdFromSourcePath(sourcePath) {
635
+ const normalized = String(sourcePath || '').replaceAll('\\', '/');
636
+ const marker = '/pages/';
637
+ const markerIndex = normalized.lastIndexOf(marker);
638
+ let routeId = markerIndex >= 0
639
+ ? normalized.slice(markerIndex + marker.length)
640
+ : normalized.split('/').pop() || normalized;
641
+ routeId = routeId.replace(/\.zen$/i, '');
642
+ if (routeId.endsWith('/index')) {
643
+ routeId = routeId.slice(0, -('/index'.length));
644
+ }
645
+ return routeId || 'index';
646
+ }
647
+
648
+ async function fileExists(fullPath) {
649
+ try {
650
+ await access(fullPath);
651
+ return true;
652
+ } catch {
653
+ return false;
654
+ }
655
+ }