@zenithbuild/cli 0.7.3 → 0.7.5

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 (84) hide show
  1. package/README.md +18 -13
  2. package/dist/adapters/adapter-netlify.d.ts +1 -1
  3. package/dist/adapters/adapter-netlify.js +56 -13
  4. package/dist/adapters/adapter-node.js +8 -0
  5. package/dist/adapters/adapter-static-export.d.ts +5 -0
  6. package/dist/adapters/adapter-static-export.js +115 -0
  7. package/dist/adapters/adapter-types.d.ts +3 -1
  8. package/dist/adapters/adapter-types.js +5 -2
  9. package/dist/adapters/adapter-vercel.d.ts +1 -1
  10. package/dist/adapters/adapter-vercel.js +70 -13
  11. package/dist/adapters/copy-hosted-page-runtime.d.ts +1 -0
  12. package/dist/adapters/copy-hosted-page-runtime.js +49 -0
  13. package/dist/adapters/resolve-adapter.js +4 -0
  14. package/dist/adapters/route-rules.d.ts +5 -0
  15. package/dist/adapters/route-rules.js +9 -0
  16. package/dist/adapters/validate-hosted-resource-routes.d.ts +1 -0
  17. package/dist/adapters/validate-hosted-resource-routes.js +13 -0
  18. package/dist/auth/route-auth.d.ts +6 -0
  19. package/dist/auth/route-auth.js +236 -0
  20. package/dist/build/compiler-runtime.d.ts +10 -9
  21. package/dist/build/compiler-runtime.js +58 -2
  22. package/dist/build/compiler-signal-expression.d.ts +1 -0
  23. package/dist/build/compiler-signal-expression.js +155 -0
  24. package/dist/build/expression-rewrites.d.ts +1 -6
  25. package/dist/build/expression-rewrites.js +61 -65
  26. package/dist/build/page-component-loop.d.ts +3 -13
  27. package/dist/build/page-component-loop.js +21 -46
  28. package/dist/build/page-ir-normalization.d.ts +0 -8
  29. package/dist/build/page-ir-normalization.js +13 -234
  30. package/dist/build/page-loop-state.d.ts +6 -9
  31. package/dist/build/page-loop-state.js +9 -8
  32. package/dist/build/page-loop.js +27 -22
  33. package/dist/build/scoped-identifier-rewrite.d.ts +37 -44
  34. package/dist/build/scoped-identifier-rewrite.js +28 -128
  35. package/dist/build/server-script.d.ts +3 -1
  36. package/dist/build/server-script.js +35 -5
  37. package/dist/build-output-manifest.d.ts +3 -2
  38. package/dist/build-output-manifest.js +3 -0
  39. package/dist/build.js +32 -18
  40. package/dist/component-instance-ir.js +158 -52
  41. package/dist/dev-build-session.js +20 -6
  42. package/dist/dev-server.js +152 -55
  43. package/dist/download-result.d.ts +14 -0
  44. package/dist/download-result.js +148 -0
  45. package/dist/framework-components/Image.zen +1 -1
  46. package/dist/images/materialization-plan.d.ts +1 -0
  47. package/dist/images/materialization-plan.js +6 -0
  48. package/dist/images/materialize.d.ts +5 -3
  49. package/dist/images/materialize.js +24 -109
  50. package/dist/images/router-manifest.d.ts +1 -0
  51. package/dist/images/router-manifest.js +49 -0
  52. package/dist/images/service.d.ts +13 -1
  53. package/dist/images/service.js +45 -15
  54. package/dist/index.js +8 -2
  55. package/dist/manifest.d.ts +15 -1
  56. package/dist/manifest.js +27 -7
  57. package/dist/preview.d.ts +13 -4
  58. package/dist/preview.js +261 -101
  59. package/dist/request-body.d.ts +1 -0
  60. package/dist/request-body.js +7 -0
  61. package/dist/request-origin.d.ts +2 -0
  62. package/dist/request-origin.js +45 -0
  63. package/dist/resource-manifest.d.ts +16 -0
  64. package/dist/resource-manifest.js +53 -0
  65. package/dist/resource-response.d.ts +34 -0
  66. package/dist/resource-response.js +71 -0
  67. package/dist/resource-route-module.d.ts +15 -0
  68. package/dist/resource-route-module.js +129 -0
  69. package/dist/route-check-support.d.ts +1 -0
  70. package/dist/route-check-support.js +4 -0
  71. package/dist/server-contract.d.ts +29 -6
  72. package/dist/server-contract.js +304 -42
  73. package/dist/server-error.d.ts +4 -0
  74. package/dist/server-error.js +36 -0
  75. package/dist/server-output.d.ts +4 -1
  76. package/dist/server-output.js +71 -10
  77. package/dist/server-runtime/node-server.js +67 -31
  78. package/dist/server-runtime/route-render.d.ts +27 -3
  79. package/dist/server-runtime/route-render.js +94 -53
  80. package/dist/server-script-composition.d.ts +13 -5
  81. package/dist/server-script-composition.js +29 -11
  82. package/dist/static-export-paths.d.ts +3 -0
  83. package/dist/static-export-paths.js +160 -0
  84. package/package.json +6 -3
@@ -15,11 +15,18 @@ import { existsSync, watch } from 'node:fs';
15
15
  import { readFile, stat } from 'node:fs/promises';
16
16
  import { performance } from 'node:perf_hooks';
17
17
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
18
+ import { appLocalRedirectLocation, imageEndpointPath, normalizeBasePath, routeCheckPath, stripBasePath } from './base-path.js';
19
+ import { resolveBuildAdapter } from './adapters/resolve-adapter.js';
18
20
  import { createDevBuildSession } from './dev-build-session.js';
19
21
  import { createStartupProfiler } from './startup-profile.js';
20
22
  import { createSilentLogger } from './ui/logger.js';
21
23
  import { readChangeFingerprint } from './dev-watch.js';
22
- import { defaultRouteDenyMessage, executeServerRoute, injectSsrPayload, loadRouteManifest, resolveWithinDist, toStaticFilePath } from './preview.js';
24
+ import { createTrustedOriginResolver, publicHost } from './request-origin.js';
25
+ import { readRequestBodyBuffer } from './request-body.js';
26
+ import { buildResourceResponseDescriptor } from './resource-response.js';
27
+ import { supportsTargetRouteCheck } from './route-check-support.js';
28
+ import { clientFacingRouteMessage, defaultRouteDenyMessage, logServerException, sanitizeRouteResult } from './server-error.js';
29
+ import { executeServerRoute, injectSsrPayload, loadRouteSurfaceState, resolveWithinDist, toStaticFilePath } from './preview.js';
23
30
  import { materializeImageMarkup } from './images/materialize.js';
24
31
  import { injectImageRuntimePayload } from './images/payload.js';
25
32
  import { handleImageRequest } from './images/service.js';
@@ -37,6 +44,15 @@ const MIME_TYPES = {
37
44
  '.avif': 'image/avif',
38
45
  '.gif': 'image/gif'
39
46
  };
47
+ const IMAGE_RUNTIME_TAG_RE = new RegExp('<' + 'script\\b[^>]*\\bid=(["\'])zenith-image-runtime\\1[^>]*>[\\s\\S]*?<\\/' + 'script>', 'i');
48
+ const EVENT_STREAM_MIME = ['text', 'event-stream'].join('/');
49
+ const LEGACY_DEV_STREAM_PATH = ['/__zenith', '_hmr'].join('');
50
+ function appendSetCookieHeaders(headers, setCookies = []) {
51
+ if (Array.isArray(setCookies) && setCookies.length > 0) {
52
+ headers['Set-Cookie'] = setCookies.slice();
53
+ }
54
+ return headers;
55
+ }
40
56
  // Note: V0 HMR script injection has been moved to the runtime client.
41
57
  // This server purely hosts the V1 HMR contract endpoints.
42
58
  /**
@@ -50,6 +66,10 @@ export async function createDevServer(options) {
50
66
  const { pagesDir, outDir, port = 3000, host = '127.0.0.1', config = {}, logger: providedLogger = null } = options;
51
67
  const logger = providedLogger || createSilentLogger();
52
68
  const buildSession = createDevBuildSession({ pagesDir, outDir, config, logger });
69
+ const configuredBasePath = normalizeBasePath(config.basePath || '/');
70
+ const resolvedTarget = resolveBuildAdapter(config).target;
71
+ const routeCheckEnabled = supportsTargetRouteCheck(resolvedTarget);
72
+ const isStaticExportTarget = resolvedTarget === 'static-export';
53
73
  const resolvedPagesDir = resolve(pagesDir);
54
74
  const resolvedOutDir = resolve(outDir);
55
75
  const resolvedOutDirTmp = resolve(dirname(resolvedOutDir), `${basename(resolvedOutDir)}.tmp`);
@@ -86,17 +106,19 @@ export async function createDevServer(options) {
86
106
  let currentCssHref = '';
87
107
  let currentCssContent = '';
88
108
  let actualPort = port;
89
- let currentRoutes = [];
109
+ const resolveServerOrigin = createTrustedOriginResolver({
110
+ host,
111
+ getPort: () => actualPort,
112
+ label: 'dev server'
113
+ });
114
+ let currentRouteState = { pageRoutes: [], resourceRoutes: [] };
90
115
  const rebuildDebounceMs = 5;
91
116
  const queuedRebuildDebounceMs = 5;
92
117
  function _publicHost() {
93
- if (host === '0.0.0.0' || host === '::') {
94
- return '127.0.0.1';
95
- }
96
- return host;
118
+ return publicHost(host);
97
119
  }
98
120
  function _serverOrigin() {
99
- return `http://${_publicHost()}:${actualPort}`;
121
+ return resolveServerOrigin();
100
122
  }
101
123
  function _trace(event, payload = {}) {
102
124
  if (!traceEnabled)
@@ -208,6 +230,9 @@ export async function createDevServer(options) {
208
230
  throw lastError;
209
231
  }
210
232
  function _buildNotFoundPayload(pathname, category, cause) {
233
+ const hintedPath = category === 'page'
234
+ ? (stripBasePath(pathname, configuredBasePath) || pathname)
235
+ : pathname;
211
236
  const payload = {
212
237
  kind: 'zenith_dev_not_found',
213
238
  category,
@@ -235,7 +260,7 @@ export async function createDevServer(options) {
235
260
  payload.docsLink = '/docs/documentation/contracts/hmr-v1-contract.md';
236
261
  return payload;
237
262
  }
238
- const routeFile = _routeFileHint(pathname);
263
+ const routeFile = _routeFileHint(hintedPath);
239
264
  payload.routeFile = routeFile;
240
265
  payload.cause = `no route file found at ${routeFile}`;
241
266
  payload.hint = `Create ${routeFile} or verify router manifest output.`;
@@ -355,22 +380,26 @@ export async function createDevServer(options) {
355
380
  return true;
356
381
  }
357
382
  async function _loadRoutesForRequests() {
358
- if (buildStatus === 'building' && Array.isArray(currentRoutes) && currentRoutes.length > 0) {
359
- return currentRoutes;
383
+ if (buildStatus === 'building' &&
384
+ ((Array.isArray(currentRouteState.pageRoutes) && currentRouteState.pageRoutes.length > 0) ||
385
+ (Array.isArray(currentRouteState.resourceRoutes) && currentRouteState.resourceRoutes.length > 0))) {
386
+ return currentRouteState;
360
387
  }
361
388
  try {
362
- const routes = await loadRouteManifest(outDir);
363
- if (Array.isArray(routes) && routes.length > 0) {
364
- currentRoutes = routes;
365
- return routes;
389
+ const routeState = await loadRouteSurfaceState(outDir, configuredBasePath);
390
+ if ((Array.isArray(routeState.pageRoutes) && routeState.pageRoutes.length > 0) ||
391
+ (Array.isArray(routeState.resourceRoutes) && routeState.resourceRoutes.length > 0)) {
392
+ currentRouteState = routeState;
393
+ return routeState;
366
394
  }
367
395
  }
368
396
  catch (error) {
369
- if (!(Array.isArray(currentRoutes) && currentRoutes.length > 0)) {
397
+ if (!(Array.isArray(currentRouteState.pageRoutes) && currentRouteState.pageRoutes.length > 0) &&
398
+ !(Array.isArray(currentRouteState.resourceRoutes) && currentRouteState.resourceRoutes.length > 0)) {
370
399
  throw error;
371
400
  }
372
401
  }
373
- return currentRoutes;
402
+ return currentRouteState;
374
403
  }
375
404
  function _broadcastEvent(type, payload = {}) {
376
405
  const eventBuildId = Number.isInteger(payload.buildId) ? payload.buildId : buildId;
@@ -403,7 +432,7 @@ export async function createDevServer(options) {
403
432
  logger.build('Initial build (id=0)', { onceKey: 'dev-initial-build' });
404
433
  const initialBuild = await buildSession.build();
405
434
  const cssReady = await _syncCssStateFromBuild(initialBuild, buildId);
406
- currentRoutes = await loadRouteManifest(outDir);
435
+ currentRouteState = await loadRouteSurfaceState(outDir, configuredBasePath);
407
436
  buildStatus = 'ok';
408
437
  buildError = null;
409
438
  lastBuildMs = Date.now();
@@ -422,7 +451,8 @@ export async function createDevServer(options) {
422
451
  status: buildStatus,
423
452
  durationMs,
424
453
  cssReady,
425
- routes: Array.isArray(currentRoutes) ? currentRoutes.length : 0
454
+ routes: (Array.isArray(currentRouteState.pageRoutes) ? currentRouteState.pageRoutes.length : 0) +
455
+ (Array.isArray(currentRouteState.resourceRoutes) ? currentRouteState.resourceRoutes.length : 0)
426
456
  });
427
457
  }
428
458
  catch (err) {
@@ -452,15 +482,12 @@ export async function createDevServer(options) {
452
482
  }
453
483
  }
454
484
  const server = createServer(async (req, res) => {
455
- const requestBase = typeof req.headers.host === 'string' && req.headers.host.length > 0
456
- ? `http://${req.headers.host}`
457
- : _serverOrigin();
458
- const url = new URL(req.url, requestBase);
485
+ const url = new URL(req.url, _serverOrigin());
459
486
  let pathname = url.pathname;
460
487
  // Legacy HMR endpoint (deprecated but kept alive to avoid breaking old caches instantly)
461
- if (pathname === '/__zenith_hmr') {
488
+ if (pathname === LEGACY_DEV_STREAM_PATH) {
462
489
  res.writeHead(200, {
463
- 'Content-Type': 'text/event-stream',
490
+ 'Content-Type': EVENT_STREAM_MIME,
464
491
  'Cache-Control': 'no-store',
465
492
  'Connection': 'keep-alive',
466
493
  'X-Zenith-Deprecated': 'true'
@@ -498,7 +525,7 @@ export async function createDevServer(options) {
498
525
  // V1 Dev Events Endpoint (SSE)
499
526
  if (pathname === '/__zenith_dev/events') {
500
527
  res.writeHead(200, {
501
- 'Content-Type': 'text/event-stream',
528
+ 'Content-Type': EVENT_STREAM_MIME,
502
529
  'Cache-Control': 'no-store',
503
530
  'Connection': 'keep-alive',
504
531
  'X-Accel-Buffering': 'no'
@@ -565,7 +592,10 @@ export async function createDevServer(options) {
565
592
  res.end(currentCssContent);
566
593
  return;
567
594
  }
568
- if (pathname === '/_zenith/image') {
595
+ if (pathname === imageEndpointPath(configuredBasePath)) {
596
+ if (isStaticExportTarget) {
597
+ throw new Error('not found');
598
+ }
569
599
  await handleImageRequest(req, res, {
570
600
  requestUrl: url,
571
601
  projectRoot,
@@ -573,8 +603,13 @@ export async function createDevServer(options) {
573
603
  });
574
604
  return;
575
605
  }
576
- if (pathname === '/__zenith/route-check') {
606
+ if (pathname === routeCheckPath(configuredBasePath)) {
577
607
  try {
608
+ if (!routeCheckEnabled) {
609
+ res.writeHead(501, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
610
+ res.end(JSON.stringify({ error: 'route_check_unsupported' }));
611
+ return;
612
+ }
578
613
  if (!initialBuildSettled && buildStatus === 'building') {
579
614
  res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
580
615
  res.end(JSON.stringify({
@@ -602,8 +637,16 @@ export async function createDevServer(options) {
602
637
  res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
603
638
  return;
604
639
  }
640
+ const canonicalTargetPath = stripBasePath(targetUrl.pathname, configuredBasePath);
641
+ if (canonicalTargetPath === null) {
642
+ res.writeHead(404, { 'Content-Type': 'application/json' });
643
+ res.end(JSON.stringify({ error: 'route_not_found' }));
644
+ return;
645
+ }
646
+ const canonicalTargetUrl = new URL(targetUrl.toString());
647
+ canonicalTargetUrl.pathname = canonicalTargetPath;
605
648
  const routes = await _loadRoutesForRequests();
606
- const resolvedCheck = resolveRequestRoute(targetUrl, routes);
649
+ const resolvedCheck = resolveRequestRoute(canonicalTargetUrl, routes.pageRoutes || []);
607
650
  if (!resolvedCheck.matched || !resolvedCheck.route) {
608
651
  res.writeHead(404, { 'Content-Type': 'application/json' });
609
652
  res.end(JSON.stringify({ error: 'route_not_found' }));
@@ -623,16 +666,17 @@ export async function createDevServer(options) {
623
666
  });
624
667
  // Security: Enforce relative or same-origin redirects
625
668
  if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
626
- const loc = String(checkResult.result.location || '/');
669
+ const loc = appLocalRedirectLocation(checkResult.result.location || '/', configuredBasePath);
670
+ checkResult.result.location = loc;
627
671
  if (loc.includes('://') || loc.startsWith('//')) {
628
672
  try {
629
673
  const parsedLoc = new URL(loc);
630
674
  if (parsedLoc.origin !== targetUrl.origin) {
631
- checkResult.result.location = '/'; // Fallback to root for open redirect attempt
675
+ checkResult.result.location = appLocalRedirectLocation('/', configuredBasePath);
632
676
  }
633
677
  }
634
678
  catch {
635
- checkResult.result.location = '/';
679
+ checkResult.result.location = appLocalRedirectLocation('/', configuredBasePath);
636
680
  }
637
681
  }
638
682
  }
@@ -644,7 +688,7 @@ export async function createDevServer(options) {
644
688
  'Vary': 'Cookie'
645
689
  });
646
690
  res.end(JSON.stringify({
647
- result: checkResult?.result || checkResult,
691
+ result: sanitizeRouteResult(checkResult?.result || checkResult),
648
692
  routeId: resolvedCheck.route.route_id || '',
649
693
  to: targetUrl.toString()
650
694
  }));
@@ -659,6 +703,7 @@ export async function createDevServer(options) {
659
703
  let resolvedPathFor404 = null;
660
704
  let staticRootFor404 = null;
661
705
  try {
706
+ const canonicalPath = stripBasePath(pathname, configuredBasePath);
662
707
  if (!initialBuildSettled && buildStatus === 'building') {
663
708
  const pendingPayload = {
664
709
  kind: 'zenith_dev_build_pending',
@@ -689,11 +734,19 @@ export async function createDevServer(options) {
689
734
  ].join(''));
690
735
  return;
691
736
  }
692
- const requestExt = extname(pathname);
737
+ if (canonicalPath === null) {
738
+ throw new Error('not found');
739
+ }
740
+ const requestExt = extname(canonicalPath);
693
741
  if (requestExt && requestExt !== '.html') {
694
- const assetPath = join(outDir, pathname);
742
+ const assetPath = isStaticExportTarget
743
+ ? resolveWithinDist(outDir, pathname)
744
+ : join(outDir, canonicalPath);
695
745
  resolvedPathFor404 = assetPath;
696
746
  staticRootFor404 = outDir;
747
+ if (!assetPath) {
748
+ throw new Error('not found');
749
+ }
697
750
  const asset = await _readFileForRequest(assetPath);
698
751
  const mime = MIME_TYPES[requestExt] || 'application/octet-stream';
699
752
  res.writeHead(200, { 'Content-Type': mime });
@@ -701,9 +754,42 @@ export async function createDevServer(options) {
701
754
  return;
702
755
  }
703
756
  const routes = await _loadRoutesForRequests();
704
- const resolved = resolveRequestRoute(url, routes);
757
+ const canonicalUrl = new URL(url.toString());
758
+ canonicalUrl.pathname = canonicalPath;
759
+ const resolvedResource = resolveRequestRoute(canonicalUrl, routes.resourceRoutes || []);
760
+ if (resolvedResource.matched && resolvedResource.route) {
761
+ const requestMethod = req.method || 'GET';
762
+ const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
763
+ ? null
764
+ : await readRequestBodyBuffer(req);
765
+ const execution = await executeServerRoute({
766
+ source: resolvedResource.route.server_script || '',
767
+ sourcePath: resolvedResource.route.server_script_path || '',
768
+ params: resolvedResource.params,
769
+ requestUrl: url.toString(),
770
+ requestMethod,
771
+ requestHeaders: req.headers,
772
+ requestBodyBuffer,
773
+ routePattern: resolvedResource.route.path,
774
+ routeFile: resolvedResource.route.server_script_path || '',
775
+ routeId: resolvedResource.route.route_id || '',
776
+ routeKind: 'resource'
777
+ });
778
+ const descriptor = buildResourceResponseDescriptor(execution?.result, configuredBasePath, Array.isArray(execution?.setCookies) ? execution.setCookies : []);
779
+ res.writeHead(descriptor.status, appendSetCookieHeaders(descriptor.headers, descriptor.setCookies));
780
+ if ((req.method || 'GET').toUpperCase() === 'HEAD') {
781
+ res.end();
782
+ return;
783
+ }
784
+ res.end(descriptor.body);
785
+ return;
786
+ }
787
+ const resolved = resolveRequestRoute(canonicalUrl, routes.pageRoutes || []);
705
788
  let filePath = null;
706
- if (resolved.matched && resolved.route) {
789
+ if (isStaticExportTarget) {
790
+ filePath = toStaticFilePath(outDir, pathname);
791
+ }
792
+ else if (resolved.matched && resolved.route) {
707
793
  if (verboseLogging) {
708
794
  logger.router(`${req.method || 'GET'} ${pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`);
709
795
  }
@@ -713,7 +799,7 @@ export async function createDevServer(options) {
713
799
  filePath = resolveWithinDist(outDir, output);
714
800
  }
715
801
  else {
716
- filePath = toStaticFilePath(outDir, pathname);
802
+ filePath = toStaticFilePath(outDir, canonicalPath);
717
803
  }
718
804
  resolvedPathFor404 = filePath;
719
805
  staticRootFor404 = outDir;
@@ -721,48 +807,56 @@ export async function createDevServer(options) {
721
807
  throw new Error('not found');
722
808
  }
723
809
  let ssrPayload = null;
810
+ let routeExecution = null;
724
811
  if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
725
- let routeExecution = null;
726
812
  try {
813
+ const requestMethod = req.method || 'GET';
814
+ const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
815
+ ? null
816
+ : await readRequestBodyBuffer(req);
727
817
  routeExecution = await executeServerRoute({
728
818
  source: resolved.route.server_script,
729
819
  sourcePath: resolved.route.server_script_path || '',
730
820
  params: resolved.params,
731
821
  requestUrl: url.toString(),
732
- requestMethod: req.method || 'GET',
822
+ requestMethod,
733
823
  requestHeaders: req.headers,
824
+ requestBodyBuffer,
734
825
  routePattern: resolved.route.path,
735
826
  routeFile: resolved.route.server_script_path || '',
736
827
  routeId: resolved.route.route_id || ''
737
828
  });
738
829
  }
739
830
  catch (error) {
831
+ logServerException('dev server route execution failed', error);
740
832
  ssrPayload = {
741
833
  __zenith_error: {
834
+ status: 500,
742
835
  code: 'LOAD_FAILED',
743
- message: error instanceof Error ? error.message : String(error)
836
+ message: error instanceof Error ? error.message : String(error || '')
744
837
  }
745
838
  };
746
839
  }
747
- const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
840
+ const trace = routeExecution?.trace || { guard: 'none', action: 'none', load: 'none' };
748
841
  const routeId = resolved.route.route_id || '';
842
+ const setCookies = Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : [];
749
843
  if (verboseLogging) {
750
- logger.router(`${routeId || resolved.route.path} guard=${trace.guard} load=${trace.load}`);
844
+ logger.router(`${routeId || resolved.route.path} guard=${trace.guard} action=${trace.action} load=${trace.load}`);
751
845
  }
752
846
  const result = routeExecution?.result;
753
847
  if (result && result.kind === 'redirect') {
754
848
  const status = Number.isInteger(result.status) ? result.status : 302;
755
- res.writeHead(status, {
756
- Location: result.location,
849
+ res.writeHead(status, appendSetCookieHeaders({
850
+ Location: appLocalRedirectLocation(result.location, configuredBasePath),
757
851
  'Cache-Control': 'no-store'
758
- });
852
+ }, setCookies));
759
853
  res.end('');
760
854
  return;
761
855
  }
762
856
  if (result && result.kind === 'deny') {
763
857
  const status = Number.isInteger(result.status) ? result.status : 403;
764
- res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
765
- res.end(result.message || defaultRouteDenyMessage(status));
858
+ res.writeHead(status, appendSetCookieHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }, setCookies));
859
+ res.end(clientFacingRouteMessage(status, result.message));
766
860
  return;
767
861
  }
768
862
  if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
@@ -770,21 +864,24 @@ export async function createDevServer(options) {
770
864
  }
771
865
  }
772
866
  let content = await _readFileForRequest(filePath, 'utf8');
773
- if (resolved.matched && resolved.route?.page_asset) {
774
- const pageAssetPath = resolveWithinDist(outDir, resolved.route.page_asset);
867
+ if (resolved.matched) {
775
868
  content = await materializeImageMarkup({
776
869
  html: content,
777
- pageAssetPath,
778
870
  payload: buildSession.getImageRuntimePayload(),
779
- ssrData: ssrPayload,
780
- routePathname: resolved.route.path || pathname
871
+ imageMaterialization: Array.isArray(resolved.route?.image_materialization)
872
+ ? resolved.route.image_materialization
873
+ : []
781
874
  });
782
875
  }
783
876
  if (ssrPayload) {
784
877
  content = injectSsrPayload(content, ssrPayload);
785
878
  }
786
- content = injectImageRuntimePayload(content, buildSession.getImageRuntimePayload());
787
- res.writeHead(200, { 'Content-Type': 'text/html' });
879
+ if (!IMAGE_RUNTIME_TAG_RE.test(content)) {
880
+ content = injectImageRuntimePayload(content, buildSession.getImageRuntimePayload());
881
+ }
882
+ res.writeHead(Number.isInteger(routeExecution?.status) ? routeExecution.status : 200, appendSetCookieHeaders({
883
+ 'Content-Type': 'text/html'
884
+ }, Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : []));
788
885
  res.end(content);
789
886
  }
790
887
  catch (error) {
@@ -907,7 +1004,7 @@ export async function createDevServer(options) {
907
1004
  const buildResult = await buildSession.build({ changedFiles, logger });
908
1005
  const cssReady = await _syncCssStateFromBuild(buildResult, cycleBuildId);
909
1006
  if (!onlyCss) {
910
- currentRoutes = await loadRouteManifest(outDir);
1007
+ currentRouteState = await loadRouteSurfaceState(outDir, configuredBasePath);
911
1008
  }
912
1009
  const cssChanged = cssReady && (currentCssAssetPath !== previousCssAssetPath ||
913
1010
  currentCssContent !== previousCssContent);
@@ -0,0 +1,14 @@
1
+ export function buildAttachmentContentDisposition(filename: any): string;
2
+ export function createDownloadResult(body: any, options?: {}): {
3
+ kind: string;
4
+ body: any;
5
+ bodyEncoding: string;
6
+ bodySize: number;
7
+ filename: string;
8
+ contentType: string;
9
+ status: number;
10
+ };
11
+ export function assertValidDownloadResult(value: any, where?: string): void;
12
+ export function decodeDownloadResultBody(result: any, where?: string): Buffer<ArrayBuffer>;
13
+ export const DOWNLOAD_PAYLOAD_LIMIT_BYTES: number;
14
+ export const DOWNLOAD_DEFAULT_CONTENT_TYPE: "application/octet-stream";
@@ -0,0 +1,148 @@
1
+ export const DOWNLOAD_PAYLOAD_LIMIT_BYTES = 5 * 1024 * 1024;
2
+ export const DOWNLOAD_DEFAULT_CONTENT_TYPE = 'application/octet-stream';
3
+ const CONTROL_CHAR_RE = /[\0-\x1F\x7F]/;
4
+ const PATH_SEPARATOR_RE = /[\\/]/;
5
+ function formatWhere(where = 'download(...)') {
6
+ return String(where || 'download(...)');
7
+ }
8
+ function normalizeFilename(filename, where = 'download(...)') {
9
+ const label = formatWhere(where);
10
+ const value = String(filename ?? '').trim();
11
+ if (!value) {
12
+ throw new Error(`[Zenith] ${label}: download filename is required.`);
13
+ }
14
+ if (CONTROL_CHAR_RE.test(value) || PATH_SEPARATOR_RE.test(value)) {
15
+ throw new Error(`[Zenith] ${label}: download filename must not contain path separators or control characters.`);
16
+ }
17
+ return value;
18
+ }
19
+ function normalizeContentType(contentType, where = 'download(...)') {
20
+ const label = formatWhere(where);
21
+ if (contentType === undefined || contentType === null) {
22
+ return DOWNLOAD_DEFAULT_CONTENT_TYPE;
23
+ }
24
+ const value = String(contentType).trim();
25
+ if (!value) {
26
+ throw new Error(`[Zenith] ${label}: download contentType must be a non-empty string when provided.`);
27
+ }
28
+ if (CONTROL_CHAR_RE.test(value)) {
29
+ throw new Error(`[Zenith] ${label}: download contentType must not contain control characters.`);
30
+ }
31
+ return value;
32
+ }
33
+ function isBlobLike(value) {
34
+ return (typeof Blob !== 'undefined' && value instanceof Blob)
35
+ || (typeof File !== 'undefined' && value instanceof File);
36
+ }
37
+ function encodeBuffer(buffer, encoding) {
38
+ return encoding === 'utf8'
39
+ ? buffer.toString('utf8')
40
+ : buffer.toString('base64');
41
+ }
42
+ function decodeBody(body, bodyEncoding, where = 'download(...)') {
43
+ const label = formatWhere(where);
44
+ if (typeof body !== 'string') {
45
+ throw new Error(`[Zenith] ${label}: download body must be a string after normalization.`);
46
+ }
47
+ if (bodyEncoding === 'utf8') {
48
+ return Buffer.from(body, 'utf8');
49
+ }
50
+ if (bodyEncoding === 'base64') {
51
+ return Buffer.from(body, 'base64');
52
+ }
53
+ throw new Error(`[Zenith] ${label}: download bodyEncoding must be "utf8" or "base64".`);
54
+ }
55
+ function normalizeBody(body, where = 'download(...)') {
56
+ const label = formatWhere(where);
57
+ if (isBlobLike(body)) {
58
+ throw new Error(`[Zenith] ${label}: download body must be string, Uint8Array, ArrayBuffer, or Buffer-compatible bytes.`);
59
+ }
60
+ if (typeof body === 'string') {
61
+ const size = Buffer.byteLength(body, 'utf8');
62
+ if (size > DOWNLOAD_PAYLOAD_LIMIT_BYTES) {
63
+ throw new Error(`[Zenith] ${label}: download payload exceeds ${DOWNLOAD_PAYLOAD_LIMIT_BYTES} bytes.`);
64
+ }
65
+ return {
66
+ body: body,
67
+ bodyEncoding: 'utf8',
68
+ bodySize: size
69
+ };
70
+ }
71
+ if (body instanceof ArrayBuffer) {
72
+ const buffer = Buffer.from(body);
73
+ if (buffer.byteLength > DOWNLOAD_PAYLOAD_LIMIT_BYTES) {
74
+ throw new Error(`[Zenith] ${label}: download payload exceeds ${DOWNLOAD_PAYLOAD_LIMIT_BYTES} bytes.`);
75
+ }
76
+ return {
77
+ body: encodeBuffer(buffer, 'base64'),
78
+ bodyEncoding: 'base64',
79
+ bodySize: buffer.byteLength
80
+ };
81
+ }
82
+ if (ArrayBuffer.isView(body)) {
83
+ const buffer = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
84
+ if (buffer.byteLength > DOWNLOAD_PAYLOAD_LIMIT_BYTES) {
85
+ throw new Error(`[Zenith] ${label}: download payload exceeds ${DOWNLOAD_PAYLOAD_LIMIT_BYTES} bytes.`);
86
+ }
87
+ return {
88
+ body: encodeBuffer(buffer, 'base64'),
89
+ bodyEncoding: 'base64',
90
+ bodySize: buffer.byteLength
91
+ };
92
+ }
93
+ throw new Error(`[Zenith] ${label}: download body must be string, Uint8Array, ArrayBuffer, or Buffer-compatible bytes.`);
94
+ }
95
+ function buildAsciiFilename(filename) {
96
+ const replaced = Array.from(String(filename || '')).map((char) => {
97
+ const code = char.charCodeAt(0);
98
+ if (code < 0x20 || code > 0x7E || char === '"' || char === '\\') {
99
+ return '_';
100
+ }
101
+ return char;
102
+ }).join('');
103
+ return replaced || 'download';
104
+ }
105
+ export function buildAttachmentContentDisposition(filename) {
106
+ const safeFilename = normalizeFilename(filename, 'download result');
107
+ const asciiFilename = buildAsciiFilename(safeFilename);
108
+ const encodedFilename = encodeURIComponent(safeFilename)
109
+ .replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
110
+ .replace(/\*/g, '%2A');
111
+ return `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`;
112
+ }
113
+ export function createDownloadResult(body, options = {}) {
114
+ const filename = normalizeFilename(options?.filename, 'download(...)');
115
+ const contentType = normalizeContentType(options?.contentType, 'download(...)');
116
+ const normalized = normalizeBody(body, 'download(...)');
117
+ return {
118
+ kind: 'download',
119
+ body: normalized.body,
120
+ bodyEncoding: normalized.bodyEncoding,
121
+ bodySize: normalized.bodySize,
122
+ filename,
123
+ contentType,
124
+ status: 200
125
+ };
126
+ }
127
+ export function assertValidDownloadResult(value, where = 'download result') {
128
+ const label = formatWhere(where);
129
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
130
+ throw new Error(`[Zenith] ${label}: download result must be an object.`);
131
+ }
132
+ normalizeFilename(value.filename, label);
133
+ normalizeContentType(value.contentType, label);
134
+ if (value.status !== 200) {
135
+ throw new Error(`[Zenith] ${label}: download status is fixed to 200 in this milestone.`);
136
+ }
137
+ if (!Number.isInteger(value.bodySize) || value.bodySize < 0 || value.bodySize > DOWNLOAD_PAYLOAD_LIMIT_BYTES) {
138
+ throw new Error(`[Zenith] ${label}: download bodySize must be an integer between 0 and ${DOWNLOAD_PAYLOAD_LIMIT_BYTES}.`);
139
+ }
140
+ const buffer = decodeBody(value.body, value.bodyEncoding, label);
141
+ if (buffer.byteLength !== value.bodySize) {
142
+ throw new Error(`[Zenith] ${label}: download bodySize does not match the normalized body.`);
143
+ }
144
+ }
145
+ export function decodeDownloadResultBody(result, where = 'download result') {
146
+ assertValidDownloadResult(result, where);
147
+ return decodeBody(result.body, result.bodyEncoding, where);
148
+ }
@@ -335,4 +335,4 @@ const imageHtml = renderImage();
335
335
  const imagePayload = serializeImageProps();
336
336
  </script>
337
337
 
338
- <span class="contents" data-zenith-image={imagePayload} innerHTML={imageHtml}></span>
338
+ <span class="contents" data-zenith-image={imagePayload} unsafeHTML={imageHtml}></span>
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Static Image props are evaluated in the Rust compiler (`zenith-compiler --merge-image-materialization`).
3
+ * See `mergePageImageMaterialization` in `build/compiler-runtime.js` and `packages/compiler/zenith_compiler/src/image_materialization.rs`.
4
+ * Legacy CLI-side TypeScript reconstruction was removed in Phase 2 Track B Sub-step 2.
5
+ */
6
+ export {};
@@ -3,12 +3,14 @@ type ImagePayload = {
3
3
  config: Record<string, unknown>;
4
4
  localImages: Record<string, unknown>;
5
5
  };
6
+ type ImageMaterializationEntry = {
7
+ selector?: string;
8
+ props?: Record<string, unknown> | null;
9
+ };
6
10
  export declare function materializeImageMarkup(options: {
7
11
  html: string;
8
- pageAssetPath?: string | null;
9
12
  payload: ImagePayload;
10
- ssrData?: Record<string, unknown> | null;
11
- routePathname?: string;
13
+ imageMaterialization?: ImageMaterializationEntry[] | null;
12
14
  }): Promise<string>;
13
15
  export declare function materializeImageMarkupInHtmlFiles(options: {
14
16
  distDir: string;