@zenithbuild/cli 0.6.6 → 0.6.9

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.
Files changed (43) hide show
  1. package/dist/build.d.ts +32 -0
  2. package/dist/build.js +193 -548
  3. package/dist/compiler-bridge-runner.d.ts +5 -0
  4. package/dist/compiler-bridge-runner.js +70 -0
  5. package/dist/component-instance-ir.d.ts +6 -0
  6. package/dist/component-instance-ir.js +0 -20
  7. package/dist/component-occurrences.d.ts +6 -0
  8. package/dist/component-occurrences.js +6 -28
  9. package/dist/dev-server.d.ts +18 -0
  10. package/dist/dev-server.js +65 -114
  11. package/dist/dev-watch.d.ts +1 -0
  12. package/dist/dev-watch.js +2 -2
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +6 -28
  15. package/dist/manifest.d.ts +23 -0
  16. package/dist/manifest.js +22 -48
  17. package/dist/preview.d.ts +100 -0
  18. package/dist/preview.js +418 -488
  19. package/dist/resolve-components.d.ts +39 -0
  20. package/dist/resolve-components.js +30 -104
  21. package/dist/server/resolve-request-route.d.ts +39 -0
  22. package/dist/server/resolve-request-route.js +104 -113
  23. package/dist/server-contract.d.ts +39 -0
  24. package/dist/server-contract.js +15 -67
  25. package/dist/toolchain-paths.d.ts +23 -0
  26. package/dist/toolchain-paths.js +139 -39
  27. package/dist/toolchain-runner.d.ts +33 -0
  28. package/dist/toolchain-runner.js +194 -0
  29. package/dist/types/generate-env-dts.d.ts +5 -0
  30. package/dist/types/generate-env-dts.js +4 -2
  31. package/dist/types/generate-routes-dts.d.ts +8 -0
  32. package/dist/types/generate-routes-dts.js +7 -5
  33. package/dist/types/index.d.ts +14 -0
  34. package/dist/types/index.js +16 -7
  35. package/dist/ui/env.d.ts +18 -0
  36. package/dist/ui/env.js +0 -12
  37. package/dist/ui/format.d.ts +33 -0
  38. package/dist/ui/format.js +8 -46
  39. package/dist/ui/logger.d.ts +59 -0
  40. package/dist/ui/logger.js +3 -32
  41. package/dist/version-check.d.ts +54 -0
  42. package/dist/version-check.js +41 -98
  43. package/package.json +6 -4
package/dist/preview.js CHANGED
@@ -8,32 +8,24 @@
8
8
  // - Executes non-prerender <script server> blocks per request and injects
9
9
  // serialized SSR payload via an inline script (`window.__zenith_ssr_data`).
10
10
  // ---------------------------------------------------------------------------
11
-
12
11
  import { spawn } from 'node:child_process';
13
12
  import { createServer } from 'node:http';
14
13
  import { access, readFile } from 'node:fs/promises';
15
14
  import { extname, join, normalize, resolve, sep, dirname } from 'node:path';
16
15
  import { fileURLToPath } from 'node:url';
17
16
  import { createSilentLogger } from './ui/logger.js';
18
- import {
19
- compareRouteSpecificity,
20
- matchRoute as matchManifestRoute,
21
- resolveRequestRoute
22
- } from './server/resolve-request-route.js';
23
-
17
+ import { compareRouteSpecificity, matchRoute as matchManifestRoute, resolveRequestRoute } from './server/resolve-request-route.js';
24
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
-
26
19
  const MIME_TYPES = {
27
- '.html': 'text/html',
28
- '.js': 'application/javascript',
29
- '.css': 'text/css',
30
- '.json': 'application/json',
31
- '.png': 'image/png',
32
- '.jpg': 'image/jpeg',
33
- '.svg': 'image/svg+xml'
20
+ '.html': 'text/html',
21
+ '.js': 'application/javascript',
22
+ '.css': 'text/css',
23
+ '.json': 'application/json',
24
+ '.png': 'image/png',
25
+ '.jpg': 'image/jpeg',
26
+ '.svg': 'image/svg+xml'
34
27
  };
35
-
36
- const SERVER_SCRIPT_RUNNER = String.raw`
28
+ const SERVER_SCRIPT_RUNNER = String.raw `
37
29
  import vm from 'node:vm';
38
30
  import fs from 'node:fs/promises';
39
31
  import path from 'node:path';
@@ -383,7 +375,6 @@ try {
383
375
  );
384
376
  }
385
377
  `;
386
-
387
378
  /**
388
379
  * Create and start a preview server.
389
380
  *
@@ -391,215 +382,196 @@ try {
391
382
  * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
392
383
  */
393
384
  export async function createPreviewServer(options) {
394
- const { distDir, port = 4000, host = '127.0.0.1', logger: providedLogger = null } = options;
395
- const logger = providedLogger || createSilentLogger();
396
- const verboseLogging = logger.mode?.logLevel === 'verbose';
397
- let actualPort = port;
398
-
399
- function publicHost() {
400
- if (host === '0.0.0.0' || host === '::') {
401
- return '127.0.0.1';
402
- }
403
- return host;
404
- }
405
-
406
- function serverOrigin() {
407
- return `http://${publicHost()}:${actualPort}`;
408
- }
409
-
410
- const server = createServer(async (req, res) => {
411
- const requestBase = typeof req.headers.host === 'string' && req.headers.host.length > 0
412
- ? `http://${req.headers.host}`
413
- : serverOrigin();
414
- const url = new URL(req.url, requestBase);
415
-
416
- try {
417
- if (url.pathname === '/__zenith/route-check') {
418
- // Security: Require explicitly designated header to prevent public oracle probing
419
- if (req.headers['x-zenith-route-check'] !== '1') {
420
- res.writeHead(403, { 'Content-Type': 'application/json' });
421
- res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
422
- return;
423
- }
424
-
425
- const targetPath = String(url.searchParams.get('path') || '/');
426
-
427
- // Security: Prevent protocol/domain injection in path
428
- if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
429
- res.writeHead(400, { 'Content-Type': 'application/json' });
430
- res.end(JSON.stringify({ error: 'invalid_path_format' }));
431
- return;
432
- }
433
-
434
- const targetUrl = new URL(targetPath, url.origin);
435
- if (targetUrl.origin !== url.origin) {
436
- res.writeHead(400, { 'Content-Type': 'application/json' });
437
- res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
438
- return;
439
- }
440
-
441
- const routes = await loadRouteManifest(distDir);
442
- const resolvedCheck = resolveRequestRoute(targetUrl, routes);
443
- if (!resolvedCheck.matched || !resolvedCheck.route) {
444
- res.writeHead(404, { 'Content-Type': 'application/json' });
445
- res.end(JSON.stringify({ error: 'route_not_found' }));
446
- return;
447
- }
448
-
449
- const checkResult = await executeServerRoute({
450
- source: resolvedCheck.route.server_script || '',
451
- sourcePath: resolvedCheck.route.server_script_path || '',
452
- params: resolvedCheck.params,
453
- requestUrl: targetUrl.toString(),
454
- requestMethod: req.method || 'GET',
455
- requestHeaders: req.headers,
456
- routePattern: resolvedCheck.route.path,
457
- routeFile: resolvedCheck.route.server_script_path || '',
458
- routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || ''),
459
- guardOnly: true
460
- });
461
- // Security: Enforce relative or same-origin redirects
462
- if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
463
- const loc = String(checkResult.result.location || '/');
464
- if (loc.includes('://') || loc.startsWith('//')) {
465
- try {
466
- const parsedLoc = new URL(loc);
467
- if (parsedLoc.origin !== targetUrl.origin) {
468
- checkResult.result.location = '/'; // Fallback to root for open redirect attempt
469
- }
470
- } catch {
471
- checkResult.result.location = '/';
472
- }
473
- }
385
+ const { distDir, port = 4000, host = '127.0.0.1', logger: providedLogger = null } = options;
386
+ const logger = providedLogger || createSilentLogger();
387
+ const verboseLogging = logger.mode?.logLevel === 'verbose';
388
+ let actualPort = port;
389
+ function publicHost() {
390
+ if (host === '0.0.0.0' || host === '::') {
391
+ return '127.0.0.1';
474
392
  }
475
-
476
- res.writeHead(200, {
477
- 'Content-Type': 'application/json',
478
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
479
- 'Pragma': 'no-cache',
480
- 'Expires': '0',
481
- 'Vary': 'Cookie'
482
- });
483
- res.end(JSON.stringify({
484
- result: checkResult?.result || checkResult,
485
- routeId: resolvedCheck.route.route_id || '',
486
- to: targetUrl.toString()
487
- }));
488
- return;
489
- }
490
-
491
- if (extname(url.pathname) && extname(url.pathname) !== '.html') {
492
- const staticPath = resolveWithinDist(distDir, url.pathname);
493
- if (!staticPath || !(await fileExists(staticPath))) {
494
- throw new Error('not found');
495
- }
496
- const content = await readFile(staticPath);
497
- const mime = MIME_TYPES[extname(staticPath)] || 'application/octet-stream';
498
- res.writeHead(200, { 'Content-Type': mime });
499
- res.end(content);
500
- return;
501
- }
502
-
503
- const routes = await loadRouteManifest(distDir);
504
- const resolved = resolveRequestRoute(url, routes);
505
- let htmlPath = null;
506
-
507
- if (resolved.matched && resolved.route) {
508
- if (verboseLogging) {
509
- logger.router(
510
- `${req.method || 'GET'} ${url.pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`
511
- );
512
- }
513
- const output = resolved.route.output.startsWith('/')
514
- ? resolved.route.output.slice(1)
515
- : resolved.route.output;
516
- htmlPath = resolveWithinDist(distDir, output);
517
- } else {
518
- htmlPath = toStaticFilePath(distDir, url.pathname);
519
- }
520
-
521
- if (!htmlPath || !(await fileExists(htmlPath))) {
522
- throw new Error('not found');
523
- }
524
-
525
- let ssrPayload = null;
526
- if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
527
- let routeExecution = null;
393
+ return host;
394
+ }
395
+ function serverOrigin() {
396
+ return `http://${publicHost()}:${actualPort}`;
397
+ }
398
+ const server = createServer(async (req, res) => {
399
+ const requestBase = typeof req.headers.host === 'string' && req.headers.host.length > 0
400
+ ? `http://${req.headers.host}`
401
+ : serverOrigin();
402
+ const url = new URL(req.url, requestBase);
528
403
  try {
529
- routeExecution = await executeServerRoute({
530
- source: resolved.route.server_script,
531
- sourcePath: resolved.route.server_script_path || '',
532
- params: resolved.params,
533
- requestUrl: url.toString(),
534
- requestMethod: req.method || 'GET',
535
- requestHeaders: req.headers,
536
- routePattern: resolved.route.path,
537
- routeFile: resolved.route.server_script_path || '',
538
- routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
539
- });
540
- } catch (error) {
541
- ssrPayload = {
542
- __zenith_error: {
543
- code: 'LOAD_FAILED',
544
- message: error instanceof Error ? error.message : String(error)
404
+ if (url.pathname === '/__zenith/route-check') {
405
+ // Security: Require explicitly designated header to prevent public oracle probing
406
+ if (req.headers['x-zenith-route-check'] !== '1') {
407
+ res.writeHead(403, { 'Content-Type': 'application/json' });
408
+ res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
409
+ return;
410
+ }
411
+ const targetPath = String(url.searchParams.get('path') || '/');
412
+ // Security: Prevent protocol/domain injection in path
413
+ if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
414
+ res.writeHead(400, { 'Content-Type': 'application/json' });
415
+ res.end(JSON.stringify({ error: 'invalid_path_format' }));
416
+ return;
417
+ }
418
+ const targetUrl = new URL(targetPath, url.origin);
419
+ if (targetUrl.origin !== url.origin) {
420
+ res.writeHead(400, { 'Content-Type': 'application/json' });
421
+ res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
422
+ return;
423
+ }
424
+ const routes = await loadRouteManifest(distDir);
425
+ const resolvedCheck = resolveRequestRoute(targetUrl, routes);
426
+ if (!resolvedCheck.matched || !resolvedCheck.route) {
427
+ res.writeHead(404, { 'Content-Type': 'application/json' });
428
+ res.end(JSON.stringify({ error: 'route_not_found' }));
429
+ return;
430
+ }
431
+ const checkResult = await executeServerRoute({
432
+ source: resolvedCheck.route.server_script || '',
433
+ sourcePath: resolvedCheck.route.server_script_path || '',
434
+ params: resolvedCheck.params,
435
+ requestUrl: targetUrl.toString(),
436
+ requestMethod: req.method || 'GET',
437
+ requestHeaders: req.headers,
438
+ routePattern: resolvedCheck.route.path,
439
+ routeFile: resolvedCheck.route.server_script_path || '',
440
+ routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || ''),
441
+ guardOnly: true
442
+ });
443
+ // Security: Enforce relative or same-origin redirects
444
+ if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
445
+ const loc = String(checkResult.result.location || '/');
446
+ if (loc.includes('://') || loc.startsWith('//')) {
447
+ try {
448
+ const parsedLoc = new URL(loc);
449
+ if (parsedLoc.origin !== targetUrl.origin) {
450
+ checkResult.result.location = '/'; // Fallback to root for open redirect attempt
451
+ }
452
+ }
453
+ catch {
454
+ checkResult.result.location = '/';
455
+ }
456
+ }
457
+ }
458
+ res.writeHead(200, {
459
+ 'Content-Type': 'application/json',
460
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
461
+ 'Pragma': 'no-cache',
462
+ 'Expires': '0',
463
+ 'Vary': 'Cookie'
464
+ });
465
+ res.end(JSON.stringify({
466
+ result: checkResult?.result || checkResult,
467
+ routeId: resolvedCheck.route.route_id || '',
468
+ to: targetUrl.toString()
469
+ }));
470
+ return;
545
471
  }
546
- };
547
- }
548
-
549
- const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
550
- const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
551
- if (verboseLogging) {
552
- logger.router(`${routeId} guard=${trace.guard} load=${trace.load}`);
553
- }
554
-
555
- const result = routeExecution?.result;
556
- if (result && result.kind === 'redirect') {
557
- const status = Number.isInteger(result.status) ? result.status : 302;
558
- res.writeHead(status, {
559
- Location: result.location,
560
- 'Cache-Control': 'no-store'
561
- });
562
- res.end('');
563
- return;
564
- }
565
- if (result && result.kind === 'deny') {
566
- const status = Number.isInteger(result.status) ? result.status : 403;
567
- res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
568
- res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
569
- return;
570
- }
571
- if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
572
- ssrPayload = result.data;
472
+ if (extname(url.pathname) && extname(url.pathname) !== '.html') {
473
+ const staticPath = resolveWithinDist(distDir, url.pathname);
474
+ if (!staticPath || !(await fileExists(staticPath))) {
475
+ throw new Error('not found');
476
+ }
477
+ const content = await readFile(staticPath);
478
+ const mime = MIME_TYPES[extname(staticPath)] || 'application/octet-stream';
479
+ res.writeHead(200, { 'Content-Type': mime });
480
+ res.end(content);
481
+ return;
482
+ }
483
+ const routes = await loadRouteManifest(distDir);
484
+ const resolved = resolveRequestRoute(url, routes);
485
+ let htmlPath = null;
486
+ if (resolved.matched && resolved.route) {
487
+ if (verboseLogging) {
488
+ logger.router(`${req.method || 'GET'} ${url.pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`);
489
+ }
490
+ const output = resolved.route.output.startsWith('/')
491
+ ? resolved.route.output.slice(1)
492
+ : resolved.route.output;
493
+ htmlPath = resolveWithinDist(distDir, output);
494
+ }
495
+ else {
496
+ htmlPath = toStaticFilePath(distDir, url.pathname);
497
+ }
498
+ if (!htmlPath || !(await fileExists(htmlPath))) {
499
+ throw new Error('not found');
500
+ }
501
+ let ssrPayload = null;
502
+ if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
503
+ let routeExecution = null;
504
+ try {
505
+ routeExecution = await executeServerRoute({
506
+ source: resolved.route.server_script,
507
+ sourcePath: resolved.route.server_script_path || '',
508
+ params: resolved.params,
509
+ requestUrl: url.toString(),
510
+ requestMethod: req.method || 'GET',
511
+ requestHeaders: req.headers,
512
+ routePattern: resolved.route.path,
513
+ routeFile: resolved.route.server_script_path || '',
514
+ routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
515
+ });
516
+ }
517
+ catch (error) {
518
+ ssrPayload = {
519
+ __zenith_error: {
520
+ code: 'LOAD_FAILED',
521
+ message: error instanceof Error ? error.message : String(error)
522
+ }
523
+ };
524
+ }
525
+ const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
526
+ const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
527
+ if (verboseLogging) {
528
+ logger.router(`${routeId} guard=${trace.guard} load=${trace.load}`);
529
+ }
530
+ const result = routeExecution?.result;
531
+ if (result && result.kind === 'redirect') {
532
+ const status = Number.isInteger(result.status) ? result.status : 302;
533
+ res.writeHead(status, {
534
+ Location: result.location,
535
+ 'Cache-Control': 'no-store'
536
+ });
537
+ res.end('');
538
+ return;
539
+ }
540
+ if (result && result.kind === 'deny') {
541
+ const status = Number.isInteger(result.status) ? result.status : 403;
542
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
543
+ res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
544
+ return;
545
+ }
546
+ if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
547
+ ssrPayload = result.data;
548
+ }
549
+ }
550
+ let html = await readFile(htmlPath, 'utf8');
551
+ if (ssrPayload) {
552
+ html = injectSsrPayload(html, ssrPayload);
553
+ }
554
+ res.writeHead(200, { 'Content-Type': 'text/html' });
555
+ res.end(html);
573
556
  }
574
- }
575
-
576
- let html = await readFile(htmlPath, 'utf8');
577
- if (ssrPayload) {
578
- html = injectSsrPayload(html, ssrPayload);
579
- }
580
-
581
- res.writeHead(200, { 'Content-Type': 'text/html' });
582
- res.end(html);
583
- } catch {
584
- res.writeHead(404, { 'Content-Type': 'text/plain' });
585
- res.end('404 Not Found');
586
- }
587
- });
588
-
589
- return new Promise((resolveServer) => {
590
- server.listen(port, host, () => {
591
- actualPort = server.address().port;
592
- resolveServer({
593
- server,
594
- port: actualPort,
595
- close: () => {
596
- server.close();
557
+ catch {
558
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
559
+ res.end('404 Not Found');
597
560
  }
598
- });
599
561
  });
600
- });
562
+ return new Promise((resolveServer) => {
563
+ server.listen(port, host, () => {
564
+ actualPort = server.address().port;
565
+ resolveServer({
566
+ server,
567
+ port: actualPort,
568
+ close: () => {
569
+ server.close();
570
+ }
571
+ });
572
+ });
573
+ });
601
574
  }
602
-
603
575
  /**
604
576
  * @typedef {{
605
577
  * path: string;
@@ -616,331 +588,289 @@ export async function createPreviewServer(options) {
616
588
  * load_module_ref?: string | null;
617
589
  * }} PreviewRoute
618
590
  */
619
-
620
591
  /**
621
592
  * @param {string} distDir
622
593
  * @returns {Promise<PreviewRoute[]>}
623
594
  */
624
595
  export async function loadRouteManifest(distDir) {
625
- const manifestPath = join(distDir, 'assets', 'router-manifest.json');
626
- try {
627
- const source = await readFile(manifestPath, 'utf8');
628
- const parsed = JSON.parse(source);
629
- const routes = Array.isArray(parsed?.routes) ? parsed.routes : [];
630
- return routes
631
- .filter((entry) =>
632
- entry &&
633
- typeof entry === 'object' &&
634
- typeof entry.path === 'string' &&
635
- typeof entry.output === 'string'
636
- )
637
- .sort((a, b) => compareRouteSpecificity(a.path, b.path));
638
- } catch {
639
- return [];
640
- }
596
+ const manifestPath = join(distDir, 'assets', 'router-manifest.json');
597
+ try {
598
+ const source = await readFile(manifestPath, 'utf8');
599
+ const parsed = JSON.parse(source);
600
+ const routes = Array.isArray(parsed?.routes) ? parsed.routes : [];
601
+ return routes
602
+ .filter((entry) => entry &&
603
+ typeof entry === 'object' &&
604
+ typeof entry.path === 'string' &&
605
+ typeof entry.output === 'string')
606
+ .sort((a, b) => compareRouteSpecificity(a.path, b.path));
607
+ }
608
+ catch {
609
+ return [];
610
+ }
641
611
  }
642
-
643
612
  export const matchRoute = matchManifestRoute;
644
-
645
613
  /**
646
614
  * @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
647
615
  * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, load: string } }>}
648
616
  */
649
- export async function executeServerRoute({
650
- source,
651
- sourcePath,
652
- params,
653
- requestUrl,
654
- requestMethod,
655
- requestHeaders,
656
- routePattern,
657
- routeFile,
658
- routeId,
659
- guardOnly = false
660
- }) {
661
- if (!source || !String(source).trim()) {
662
- return {
663
- result: { kind: 'data', data: {} },
664
- trace: { guard: 'none', load: 'none' }
665
- };
666
- }
667
-
668
- const payload = await spawnNodeServerRunner({
669
- source,
670
- sourcePath,
671
- params,
672
- requestUrl: requestUrl || 'http://localhost/',
673
- requestMethod: requestMethod || 'GET',
674
- requestHeaders: sanitizeRequestHeaders(requestHeaders || {}),
675
- routePattern: routePattern || '',
676
- routeFile: routeFile || sourcePath || '',
677
- routeId: routeId || routeIdFromSourcePath(sourcePath || ''),
678
- guardOnly
679
- });
680
-
681
- if (payload === null || payload === undefined) {
682
- return {
683
- result: { kind: 'data', data: {} },
684
- trace: { guard: 'none', load: 'none' }
685
- };
686
- }
687
- if (typeof payload !== 'object' || Array.isArray(payload)) {
688
- throw new Error('[zenith-preview] server script payload must be an object');
689
- }
690
-
691
- const errorEnvelope = payload.__zenith_error;
692
- if (errorEnvelope && typeof errorEnvelope === 'object') {
693
- return {
694
- result: {
695
- kind: 'deny',
696
- status: 500,
697
- message: String(errorEnvelope.message || 'Server route execution failed')
698
- },
699
- trace: { guard: 'none', load: 'deny' }
700
- };
701
- }
702
-
703
- const result = payload.result;
704
- const trace = payload.trace;
705
- if (result && typeof result === 'object' && !Array.isArray(result) && typeof result.kind === 'string') {
617
+ export async function executeServerRoute({ source, sourcePath, params, requestUrl, requestMethod, requestHeaders, routePattern, routeFile, routeId, guardOnly = false }) {
618
+ if (!source || !String(source).trim()) {
619
+ return {
620
+ result: { kind: 'data', data: {} },
621
+ trace: { guard: 'none', load: 'none' }
622
+ };
623
+ }
624
+ const payload = await spawnNodeServerRunner({
625
+ source,
626
+ sourcePath,
627
+ params,
628
+ requestUrl: requestUrl || 'http://localhost/',
629
+ requestMethod: requestMethod || 'GET',
630
+ requestHeaders: sanitizeRequestHeaders(requestHeaders || {}),
631
+ routePattern: routePattern || '',
632
+ routeFile: routeFile || sourcePath || '',
633
+ routeId: routeId || routeIdFromSourcePath(sourcePath || ''),
634
+ guardOnly
635
+ });
636
+ if (payload === null || payload === undefined) {
637
+ return {
638
+ result: { kind: 'data', data: {} },
639
+ trace: { guard: 'none', load: 'none' }
640
+ };
641
+ }
642
+ if (typeof payload !== 'object' || Array.isArray(payload)) {
643
+ throw new Error('[zenith-preview] server script payload must be an object');
644
+ }
645
+ const errorEnvelope = payload.__zenith_error;
646
+ if (errorEnvelope && typeof errorEnvelope === 'object') {
647
+ return {
648
+ result: {
649
+ kind: 'deny',
650
+ status: 500,
651
+ message: String(errorEnvelope.message || 'Server route execution failed')
652
+ },
653
+ trace: { guard: 'none', load: 'deny' }
654
+ };
655
+ }
656
+ const result = payload.result;
657
+ const trace = payload.trace;
658
+ if (result && typeof result === 'object' && !Array.isArray(result) && typeof result.kind === 'string') {
659
+ return {
660
+ result,
661
+ trace: trace && typeof trace === 'object'
662
+ ? {
663
+ guard: String(trace.guard || 'none'),
664
+ load: String(trace.load || 'none')
665
+ }
666
+ : { guard: 'none', load: 'none' }
667
+ };
668
+ }
706
669
  return {
707
- result,
708
- trace: trace && typeof trace === 'object'
709
- ? {
710
- guard: String(trace.guard || 'none'),
711
- load: String(trace.load || 'none')
712
- }
713
- : { guard: 'none', load: 'none' }
670
+ result: {
671
+ kind: 'data',
672
+ data: payload
673
+ },
674
+ trace: { guard: 'none', load: 'data' }
714
675
  };
715
- }
716
-
717
- return {
718
- result: {
719
- kind: 'data',
720
- data: payload
721
- },
722
- trace: { guard: 'none', load: 'data' }
723
- };
724
676
  }
725
-
726
677
  /**
727
678
  * @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
728
679
  * @returns {Promise<Record<string, unknown> | null>}
729
680
  */
730
681
  export async function executeServerScript(input) {
731
- const execution = await executeServerRoute(input);
732
- const result = execution?.result;
733
- if (!result || typeof result !== 'object') {
734
- return null;
735
- }
736
- if (result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
737
- return result.data;
738
- }
739
-
740
- if (result.kind === 'redirect') {
741
- return {
742
- __zenith_error: {
743
- status: Number.isInteger(result.status) ? result.status : 302,
744
- code: 'REDIRECT',
745
- message: `Redirect to ${String(result.location || '')}`
746
- }
747
- };
748
- }
749
-
750
- if (result.kind === 'deny') {
751
- return {
752
- __zenith_error: {
753
- status: Number.isInteger(result.status) ? result.status : 403,
754
- code: 'ACCESS_DENIED',
755
- message: String(result.message || 'Access denied')
756
- }
757
- };
758
- }
759
-
760
- return {};
682
+ const execution = await executeServerRoute(input);
683
+ const result = execution?.result;
684
+ if (!result || typeof result !== 'object') {
685
+ return null;
686
+ }
687
+ if (result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
688
+ return result.data;
689
+ }
690
+ if (result.kind === 'redirect') {
691
+ return {
692
+ __zenith_error: {
693
+ status: Number.isInteger(result.status) ? result.status : 302,
694
+ code: 'REDIRECT',
695
+ message: `Redirect to ${String(result.location || '')}`
696
+ }
697
+ };
698
+ }
699
+ if (result.kind === 'deny') {
700
+ return {
701
+ __zenith_error: {
702
+ status: Number.isInteger(result.status) ? result.status : 403,
703
+ code: 'ACCESS_DENIED',
704
+ message: String(result.message || 'Access denied')
705
+ }
706
+ };
707
+ }
708
+ return {};
761
709
  }
762
-
763
710
  /**
764
711
  * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl: string, requestMethod: string, requestHeaders: Record<string, string>, routePattern: string, routeFile: string, routeId: string }} input
765
712
  * @returns {Promise<unknown>}
766
713
  */
767
714
  function spawnNodeServerRunner(input) {
768
- return new Promise((resolvePromise, rejectPromise) => {
769
- const child = spawn(
770
- process.execPath,
771
- ['--experimental-vm-modules', '--input-type=module', '-e', SERVER_SCRIPT_RUNNER],
772
- {
773
- env: {
774
- ...process.env,
775
- ZENITH_SERVER_SOURCE: input.source,
776
- ZENITH_SERVER_SOURCE_PATH: input.sourcePath || '',
777
- ZENITH_SERVER_PARAMS: JSON.stringify(input.params || {}),
778
- ZENITH_SERVER_REQUEST_URL: input.requestUrl || 'http://localhost/',
779
- ZENITH_SERVER_REQUEST_METHOD: input.requestMethod || 'GET',
780
- ZENITH_SERVER_REQUEST_HEADERS: JSON.stringify(input.requestHeaders || {}),
781
- ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
782
- ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
783
- ZENITH_SERVER_ROUTE_ID: input.routeId || '',
784
- ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
785
- ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
786
- },
787
- stdio: ['ignore', 'pipe', 'pipe']
788
- }
789
- );
790
-
791
- let stdout = '';
792
- let stderr = '';
793
- child.stdout.on('data', (chunk) => {
794
- stdout += String(chunk);
795
- });
796
- child.stderr.on('data', (chunk) => {
797
- stderr += String(chunk);
798
- });
799
- child.on('error', (error) => {
800
- rejectPromise(error);
801
- });
802
- child.on('close', (code) => {
803
- if (code !== 0) {
804
- rejectPromise(
805
- new Error(
806
- `[zenith-preview] server script execution failed (${code}): ${stderr.trim() || stdout.trim()}`
807
- )
808
- );
809
- return;
810
- }
811
- const raw = stdout.trim();
812
- if (!raw || raw === 'null') {
813
- resolvePromise(null);
814
- return;
815
- }
816
- try {
817
- resolvePromise(JSON.parse(raw));
818
- } catch (error) {
819
- rejectPromise(
820
- new Error(
821
- `[zenith-preview] invalid server payload JSON: ${error instanceof Error ? error.message : String(error)
822
- }`
823
- )
824
- );
825
- }
715
+ return new Promise((resolvePromise, rejectPromise) => {
716
+ const child = spawn(process.execPath, ['--experimental-vm-modules', '--input-type=module', '-e', SERVER_SCRIPT_RUNNER], {
717
+ env: {
718
+ ...process.env,
719
+ ZENITH_SERVER_SOURCE: input.source,
720
+ ZENITH_SERVER_SOURCE_PATH: input.sourcePath || '',
721
+ ZENITH_SERVER_PARAMS: JSON.stringify(input.params || {}),
722
+ ZENITH_SERVER_REQUEST_URL: input.requestUrl || 'http://localhost/',
723
+ ZENITH_SERVER_REQUEST_METHOD: input.requestMethod || 'GET',
724
+ ZENITH_SERVER_REQUEST_HEADERS: JSON.stringify(input.requestHeaders || {}),
725
+ ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
726
+ ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
727
+ ZENITH_SERVER_ROUTE_ID: input.routeId || '',
728
+ ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
729
+ ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
730
+ },
731
+ stdio: ['ignore', 'pipe', 'pipe']
732
+ });
733
+ let stdout = '';
734
+ let stderr = '';
735
+ child.stdout.on('data', (chunk) => {
736
+ stdout += String(chunk);
737
+ });
738
+ child.stderr.on('data', (chunk) => {
739
+ stderr += String(chunk);
740
+ });
741
+ child.on('error', (error) => {
742
+ rejectPromise(error);
743
+ });
744
+ child.on('close', (code) => {
745
+ if (code !== 0) {
746
+ rejectPromise(new Error(`[zenith-preview] server script execution failed (${code}): ${stderr.trim() || stdout.trim()}`));
747
+ return;
748
+ }
749
+ const raw = stdout.trim();
750
+ if (!raw || raw === 'null') {
751
+ resolvePromise(null);
752
+ return;
753
+ }
754
+ try {
755
+ resolvePromise(JSON.parse(raw));
756
+ }
757
+ catch (error) {
758
+ rejectPromise(new Error(`[zenith-preview] invalid server payload JSON: ${error instanceof Error ? error.message : String(error)}`));
759
+ }
760
+ });
826
761
  });
827
- });
828
762
  }
829
-
830
763
  /**
831
764
  * @param {string} html
832
765
  * @param {Record<string, unknown>} payload
833
766
  * @returns {string}
834
767
  */
835
768
  export function injectSsrPayload(html, payload) {
836
- const serialized = serializeInlineScriptJson(payload);
837
- const scriptTag = `<script id="zenith-ssr-data">window.__zenith_ssr_data = ${serialized};</script>`;
838
- const existingTagRe = /<script\b[^>]*\bid=(["'])zenith-ssr-data\1[^>]*>[\s\S]*?<\/script>/i;
839
- if (existingTagRe.test(html)) {
840
- return html.replace(existingTagRe, scriptTag);
841
- }
842
-
843
- const headClose = html.match(/<\/head>/i);
844
- if (headClose) {
845
- return html.replace(/<\/head>/i, `${scriptTag}</head>`);
846
- }
847
-
848
- const bodyOpen = html.match(/<body\b[^>]*>/i);
849
- if (bodyOpen) {
850
- return html.replace(bodyOpen[0], `${bodyOpen[0]}${scriptTag}`);
851
- }
852
-
853
- return `${scriptTag}${html}`;
769
+ const serialized = serializeInlineScriptJson(payload);
770
+ const scriptTag = `<script id="zenith-ssr-data">window.__zenith_ssr_data = ${serialized};</script>`;
771
+ const existingTagRe = /<script\b[^>]*\bid=(["'])zenith-ssr-data\1[^>]*>[\s\S]*?<\/script>/i;
772
+ if (existingTagRe.test(html)) {
773
+ return html.replace(existingTagRe, scriptTag);
774
+ }
775
+ const headClose = html.match(/<\/head>/i);
776
+ if (headClose) {
777
+ return html.replace(/<\/head>/i, `${scriptTag}</head>`);
778
+ }
779
+ const bodyOpen = html.match(/<body\b[^>]*>/i);
780
+ if (bodyOpen) {
781
+ return html.replace(bodyOpen[0], `${bodyOpen[0]}${scriptTag}`);
782
+ }
783
+ return `${scriptTag}${html}`;
854
784
  }
855
-
856
785
  /**
857
786
  * @param {Record<string, unknown>} payload
858
787
  * @returns {string}
859
788
  */
860
789
  function serializeInlineScriptJson(payload) {
861
- return JSON.stringify(payload)
862
- .replace(/</g, '\\u003C')
863
- .replace(/>/g, '\\u003E')
864
- .replace(/\//g, '\\u002F')
865
- .replace(/\u2028/g, '\\u2028')
866
- .replace(/\u2029/g, '\\u2029');
790
+ return JSON.stringify(payload)
791
+ .replace(/</g, '\\u003C')
792
+ .replace(/>/g, '\\u003E')
793
+ .replace(/\//g, '\\u002F')
794
+ .replace(/\u2028/g, '\\u2028')
795
+ .replace(/\u2029/g, '\\u2029');
867
796
  }
868
-
869
797
  export function toStaticFilePath(distDir, pathname) {
870
- let resolved = pathname;
871
- if (resolved === '/') {
872
- resolved = '/index.html';
873
- } else if (!extname(resolved)) {
874
- resolved += '/index.html';
875
- }
876
- return resolveWithinDist(distDir, resolved);
798
+ let resolved = pathname;
799
+ if (resolved === '/') {
800
+ resolved = '/index.html';
801
+ }
802
+ else if (!extname(resolved)) {
803
+ resolved += '/index.html';
804
+ }
805
+ return resolveWithinDist(distDir, resolved);
877
806
  }
878
-
879
807
  export function resolveWithinDist(distDir, requestPath) {
880
- let decoded = requestPath;
881
- try {
882
- decoded = decodeURIComponent(requestPath);
883
- } catch {
808
+ let decoded = requestPath;
809
+ try {
810
+ decoded = decodeURIComponent(requestPath);
811
+ }
812
+ catch {
813
+ return null;
814
+ }
815
+ const normalized = normalize(decoded).replace(/\\/g, '/');
816
+ const relative = normalized.replace(/^\/+/, '');
817
+ const root = resolve(distDir);
818
+ const candidate = resolve(root, relative);
819
+ if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
820
+ return candidate;
821
+ }
884
822
  return null;
885
- }
886
-
887
- const normalized = normalize(decoded).replace(/\\/g, '/');
888
- const relative = normalized.replace(/^\/+/, '');
889
- const root = resolve(distDir);
890
- const candidate = resolve(root, relative);
891
- if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
892
- return candidate;
893
- }
894
- return null;
895
823
  }
896
-
897
824
  /**
898
825
  * @param {Record<string, string | string[] | undefined>} headers
899
826
  * @returns {Record<string, string>}
900
827
  */
901
828
  function sanitizeRequestHeaders(headers) {
902
- const out = Object.create(null);
903
- const denyExact = new Set(['proxy-authorization', 'set-cookie']);
904
- const denyPrefixes = ['x-forwarded-', 'cf-'];
905
- for (const [rawKey, rawValue] of Object.entries(headers || {})) {
906
- const key = String(rawKey || '').toLowerCase();
907
- if (!key) continue;
908
- if (denyExact.has(key)) continue;
909
- if (denyPrefixes.some((prefix) => key.startsWith(prefix))) continue;
910
- let value = '';
911
- if (Array.isArray(rawValue)) {
912
- value = rawValue.filter((entry) => entry !== undefined).map(String).join(', ');
913
- } else if (rawValue !== undefined) {
914
- value = String(rawValue);
829
+ const out = Object.create(null);
830
+ const denyExact = new Set(['proxy-authorization', 'set-cookie']);
831
+ const denyPrefixes = ['x-forwarded-', 'cf-'];
832
+ for (const [rawKey, rawValue] of Object.entries(headers || {})) {
833
+ const key = String(rawKey || '').toLowerCase();
834
+ if (!key)
835
+ continue;
836
+ if (denyExact.has(key))
837
+ continue;
838
+ if (denyPrefixes.some((prefix) => key.startsWith(prefix)))
839
+ continue;
840
+ let value = '';
841
+ if (Array.isArray(rawValue)) {
842
+ value = rawValue.filter((entry) => entry !== undefined).map(String).join(', ');
843
+ }
844
+ else if (rawValue !== undefined) {
845
+ value = String(rawValue);
846
+ }
847
+ out[key] = value;
915
848
  }
916
- out[key] = value;
917
- }
918
- return out;
849
+ return out;
919
850
  }
920
-
921
851
  /**
922
852
  * @param {string} sourcePath
923
853
  * @returns {string}
924
854
  */
925
855
  function routeIdFromSourcePath(sourcePath) {
926
- const normalized = String(sourcePath || '').replaceAll('\\', '/');
927
- const marker = '/pages/';
928
- const markerIndex = normalized.lastIndexOf(marker);
929
- let routeId = markerIndex >= 0
930
- ? normalized.slice(markerIndex + marker.length)
931
- : normalized.split('/').pop() || normalized;
932
- routeId = routeId.replace(/\.zen$/i, '');
933
- if (routeId.endsWith('/index')) {
934
- routeId = routeId.slice(0, -('/index'.length));
935
- }
936
- return routeId || 'index';
856
+ const normalized = String(sourcePath || '').replaceAll('\\', '/');
857
+ const marker = '/pages/';
858
+ const markerIndex = normalized.lastIndexOf(marker);
859
+ let routeId = markerIndex >= 0
860
+ ? normalized.slice(markerIndex + marker.length)
861
+ : normalized.split('/').pop() || normalized;
862
+ routeId = routeId.replace(/\.zen$/i, '');
863
+ if (routeId.endsWith('/index')) {
864
+ routeId = routeId.slice(0, -('/index'.length));
865
+ }
866
+ return routeId || 'index';
937
867
  }
938
-
939
868
  async function fileExists(fullPath) {
940
- try {
941
- await access(fullPath);
942
- return true;
943
- } catch {
944
- return false;
945
- }
869
+ try {
870
+ await access(fullPath);
871
+ return true;
872
+ }
873
+ catch {
874
+ return false;
875
+ }
946
876
  }