@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
package/dist/preview.js CHANGED
@@ -14,10 +14,17 @@ import { access, readFile } from 'node:fs/promises';
14
14
  import { extname, join, normalize, resolve, sep, dirname } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { appLocalRedirectLocation, imageEndpointPath, normalizeBasePath, routeCheckPath, stripBasePath } from './base-path.js';
17
- import { isConfigKeyExplicit, loadConfig, validateConfig } from './config.js';
17
+ import { resolveBuildAdapter } from './adapters/resolve-adapter.js';
18
+ import { isConfigKeyExplicit, isLoadedConfig, loadConfig, validateConfig } from './config.js';
18
19
  import { materializeImageMarkup } from './images/materialize.js';
19
20
  import { createImageRuntimePayload, injectImageRuntimePayload } from './images/payload.js';
20
21
  import { handleImageRequest } from './images/service.js';
22
+ import { readRequestBodyBuffer } from './request-body.js';
23
+ import { createTrustedOriginResolver } from './request-origin.js';
24
+ import { buildResourceResponseDescriptor } from './resource-response.js';
25
+ import { loadResourceRouteManifest } from './resource-manifest.js';
26
+ import { supportsTargetRouteCheck } from './route-check-support.js';
27
+ import { clientFacingRouteMessage, defaultRouteDenyMessage, logServerException, sanitizeRouteResult } from './server-error.js';
21
28
  import { createSilentLogger } from './ui/logger.js';
22
29
  import { compareRouteSpecificity, matchRoute as matchManifestRoute, resolveRequestRoute } from './server/resolve-request-route.js';
23
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -34,6 +41,13 @@ const MIME_TYPES = {
34
41
  '.avif': 'image/avif',
35
42
  '.gif': 'image/gif'
36
43
  };
44
+ const IMAGE_RUNTIME_TAG_RE = /<script\b[^>]*\bid=(["'])zenith-image-runtime\1[^>]*>[\s\S]*?<\/script>/i;
45
+ function appendSetCookieHeaders(headers, setCookies = []) {
46
+ if (Array.isArray(setCookies) && setCookies.length > 0) {
47
+ headers['Set-Cookie'] = setCookies.slice();
48
+ }
49
+ return headers;
50
+ }
37
51
  const SERVER_SCRIPT_RUNNER = String.raw `
38
52
  import vm from 'node:vm';
39
53
  import fs from 'node:fs/promises';
@@ -49,6 +63,7 @@ const requestHeaders = JSON.parse(process.env.ZENITH_SERVER_REQUEST_HEADERS || '
49
63
  const routePattern = process.env.ZENITH_SERVER_ROUTE_PATTERN || '';
50
64
  const routeFile = process.env.ZENITH_SERVER_ROUTE_FILE || sourcePath || '';
51
65
  const routeId = process.env.ZENITH_SERVER_ROUTE_ID || routePattern || '';
66
+ const routeKind = process.env.ZENITH_SERVER_ROUTE_KIND || 'page';
52
67
  const guardOnly = process.env.ZENITH_SERVER_GUARD_ONLY === '1';
53
68
 
54
69
  if (!source.trim()) {
@@ -203,17 +218,55 @@ function ctxDeny(status = 403, message = undefined) {
203
218
  message: typeof message === 'string' ? message : undefined
204
219
  };
205
220
  }
221
+ function ctxInvalid(payload, status = 400) {
222
+ return {
223
+ kind: 'invalid',
224
+ data: payload,
225
+ status: Number.isInteger(status) ? status : 400
226
+ };
227
+ }
206
228
  function ctxData(payload) {
207
229
  return {
208
230
  kind: 'data',
209
231
  data: payload
210
232
  };
211
233
  }
234
+ function ctxJson(payload, status = 200) {
235
+ return {
236
+ kind: 'json',
237
+ data: payload,
238
+ status: Number.isInteger(status) ? status : 200
239
+ };
240
+ }
241
+ function ctxText(body, status = 200) {
242
+ return {
243
+ kind: 'text',
244
+ body: typeof body === 'string' ? body : String(body ?? ''),
245
+ status: Number.isInteger(status) ? status : 200
246
+ };
247
+ }
212
248
 
213
- const requestSnapshot = new Request(requestUrl, {
249
+ async function readStdinBuffer() {
250
+ const chunks = [];
251
+ for await (const chunk of process.stdin) {
252
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
253
+ }
254
+ return Buffer.concat(chunks);
255
+ }
256
+
257
+ const requestInit = {
214
258
  method: requestMethod,
215
259
  headers: new Headers(safeRequestHeaders)
216
- });
260
+ };
261
+ const requestBodyBuffer =
262
+ requestMethod !== 'GET' && requestMethod !== 'HEAD'
263
+ ? await readStdinBuffer()
264
+ : Buffer.alloc(0);
265
+ if (requestMethod !== 'GET' && requestMethod !== 'HEAD' && requestBodyBuffer.length > 0) {
266
+ requestInit.body = requestBodyBuffer;
267
+ requestInit.duplex = 'half';
268
+ }
269
+ const requestSnapshot = new Request(requestUrl, requestInit);
217
270
  const routeParams = { ...params };
218
271
  const routeMeta = {
219
272
  id: routeId,
@@ -229,27 +282,28 @@ const routeContext = {
229
282
  method: requestMethod,
230
283
  route: routeMeta,
231
284
  env: {},
232
- auth: {
233
- async getSession(_ctx) {
234
- return null;
235
- },
236
- async requireSession(_ctx) {
237
- throw ctxRedirect('/login', 302);
238
- }
239
- },
285
+ action: null,
240
286
  allow: ctxAllow,
241
287
  redirect: ctxRedirect,
242
288
  deny: ctxDeny,
243
- data: ctxData
289
+ invalid: ctxInvalid,
290
+ data: ctxData,
291
+ json: ctxJson,
292
+ text: ctxText
244
293
  };
245
294
 
246
295
  const context = vm.createContext({
247
296
  params: routeParams,
248
297
  ctx: routeContext,
249
298
  fetch: globalThis.fetch,
299
+ Blob: globalThis.Blob,
300
+ File: globalThis.File,
301
+ FormData: globalThis.FormData,
250
302
  Headers: globalThis.Headers,
251
303
  Request: globalThis.Request,
252
304
  Response: globalThis.Response,
305
+ TextEncoder: globalThis.TextEncoder,
306
+ TextDecoder: globalThis.TextDecoder,
253
307
  URL,
254
308
  URLSearchParams,
255
309
  Buffer,
@@ -293,23 +347,31 @@ async function loadFileModule(moduleUrl) {
293
347
  if (moduleCache.has(moduleUrl)) {
294
348
  return moduleCache.get(moduleUrl);
295
349
  }
350
+ const modulePromise = (async () => {
351
+ const filename = fileURLToPath(moduleUrl);
352
+ let code = await fs.readFile(filename, 'utf8');
353
+ code = await transpileIfNeeded(filename, code);
354
+ const module = new vm.SourceTextModule(code, {
355
+ context,
356
+ identifier: moduleUrl,
357
+ initializeImportMeta(meta) {
358
+ meta.url = moduleUrl;
359
+ }
360
+ });
296
361
 
297
- const filename = fileURLToPath(moduleUrl);
298
- let code = await fs.readFile(filename, 'utf8');
299
- code = await transpileIfNeeded(filename, code);
300
- const module = new vm.SourceTextModule(code, {
301
- context,
302
- identifier: moduleUrl,
303
- initializeImportMeta(meta) {
304
- meta.url = moduleUrl;
305
- }
306
- });
362
+ await module.link((specifier, referencingModule) => {
363
+ return linkModule(specifier, referencingModule.identifier);
364
+ });
365
+ return module;
366
+ })();
307
367
 
308
- moduleCache.set(moduleUrl, module);
309
- await module.link((specifier, referencingModule) => {
310
- return linkModule(specifier, referencingModule.identifier);
311
- });
312
- return module;
368
+ moduleCache.set(moduleUrl, modulePromise);
369
+ try {
370
+ return await modulePromise;
371
+ } catch (error) {
372
+ moduleCache.delete(moduleUrl);
373
+ throw error;
374
+ }
313
375
  }
314
376
 
315
377
  async function linkModule(specifier, parentIdentifier) {
@@ -320,11 +382,14 @@ async function linkModule(specifier, parentIdentifier) {
320
382
  return loadFileModule(resolvedUrl);
321
383
  }
322
384
 
323
- const allowed = new Set(['data', 'load', 'guard', 'ssr_data', 'props', 'ssr', 'prerender']);
385
+ const allowed = new Set(['data', 'load', 'guard', 'action', 'ssr_data', 'props', 'ssr', 'prerender', 'exportPaths']);
324
386
  const prelude = "const params = globalThis.params;\n" +
325
387
  "const ctx = globalThis.ctx;\n" +
326
- "import { resolveRouteResult } from 'zenith:server-contract';\n" +
327
- "globalThis.resolveRouteResult = resolveRouteResult;\n";
388
+ "import { download, resolveRouteResult } from 'zenith:server-contract';\n" +
389
+ "import { attachRouteAuth } from 'zenith:route-auth';\n" +
390
+ "ctx.download = download;\n" +
391
+ "globalThis.resolveRouteResult = resolveRouteResult;\n" +
392
+ "globalThis.attachRouteAuth = attachRouteAuth;\n";
328
393
  const entryIdentifier = sourcePath
329
394
  ? pathToFileURL(sourcePath).href
330
395
  : 'zenith:server-script';
@@ -345,14 +410,35 @@ moduleCache.set(entryIdentifier, entryModule);
345
410
  await entryModule.link((specifier, referencingModule) => {
346
411
  if (specifier === 'zenith:server-contract') {
347
412
  const defaultPath = path.join(process.cwd(), 'node_modules', '@zenithbuild', 'cli', 'src', 'server-contract.js');
348
- const contractUrl = pathToFileURL(process.env.ZENITH_SERVER_CONTRACT_PATH || defaultPath).href;
349
- return loadFileModule(contractUrl).catch(() =>
413
+ const configuredPath = process.env.ZENITH_SERVER_CONTRACT_PATH || '';
414
+ const contractUrl = pathToFileURL(configuredPath || defaultPath).href;
415
+ if (configuredPath) {
416
+ return loadFileModule(contractUrl);
417
+ }
418
+ return loadFileModule(contractUrl).catch(() =>
419
+ loadFileModule(pathToFileURL(defaultPath).href)
420
+ );
421
+ }
422
+ if (specifier === 'zenith:route-auth') {
423
+ const defaultPath = path.join(process.cwd(), 'node_modules', '@zenithbuild', 'cli', 'src', 'auth', 'route-auth.js');
424
+ const configuredPath = process.env.ZENITH_SERVER_ROUTE_AUTH_PATH || '';
425
+ const authUrl = pathToFileURL(configuredPath || defaultPath).href;
426
+ if (configuredPath) {
427
+ return loadFileModule(authUrl);
428
+ }
429
+ return loadFileModule(authUrl).catch(() =>
350
430
  loadFileModule(pathToFileURL(defaultPath).href)
351
431
  );
352
432
  }
353
433
  return linkModule(specifier, referencingModule.identifier);
354
434
  });
355
435
  await entryModule.evaluate();
436
+ context.attachRouteAuth(routeContext, {
437
+ requestUrl: routeContext.url,
438
+ guardOnly,
439
+ redirect: ctxRedirect,
440
+ deny: ctxDeny
441
+ });
356
442
 
357
443
  const namespaceKeys = Object.keys(entryModule.namespace);
358
444
  for (const key of namespaceKeys) {
@@ -367,18 +453,21 @@ try {
367
453
  exports: exported,
368
454
  ctx: context.ctx,
369
455
  filePath: sourcePath || 'server_script',
370
- guardOnly: guardOnly
456
+ guardOnly: guardOnly,
457
+ routeKind: routeKind
371
458
  });
372
459
 
373
460
  process.stdout.write(JSON.stringify(resolved || null));
374
461
  } catch (error) {
375
- const message = error instanceof Error ? error.message : String(error);
462
+ const message = error instanceof Error
463
+ ? (typeof error.stack === 'string' && error.stack.length > 0 ? error.stack : error.message)
464
+ : String(error);
465
+ process.stderr.write('[Zenith:Server] preview route execution failed\\n' + message + '\\n');
376
466
  process.stdout.write(
377
467
  JSON.stringify({
378
468
  __zenith_error: {
379
469
  status: 500,
380
- code: 'LOAD_FAILED',
381
- message
470
+ code: 'LOAD_FAILED'
382
471
  }
383
472
  })
384
473
  );
@@ -395,7 +484,9 @@ export async function createPreviewServer(options) {
395
484
  const loadedConfig = await loadConfig(resolvedProjectRoot);
396
485
  const resolvedConfig = options?.config && typeof options.config === 'object'
397
486
  ? (() => {
398
- const overrideConfig = validateConfig(options.config);
487
+ const overrideConfig = isLoadedConfig(options.config)
488
+ ? options.config
489
+ : validateConfig(options.config);
399
490
  const mergedConfig = { ...loadedConfig };
400
491
  for (const key of Object.keys(overrideConfig)) {
401
492
  if (isConfigKeyExplicit(overrideConfig, key)) {
@@ -411,7 +502,15 @@ export async function createPreviewServer(options) {
411
502
  const logger = providedLogger || createSilentLogger();
412
503
  const verboseLogging = logger.mode?.logLevel === 'verbose';
413
504
  const configuredBasePath = normalizeBasePath(config.basePath || '/');
505
+ const resolvedTarget = resolveBuildAdapter(config).target;
506
+ const routeCheckEnabled = supportsTargetRouteCheck(resolvedTarget);
507
+ const isStaticExportTarget = resolvedTarget === 'static-export';
414
508
  let actualPort = port;
509
+ const resolveServerOrigin = createTrustedOriginResolver({
510
+ host,
511
+ getPort: () => actualPort,
512
+ label: 'preview server'
513
+ });
415
514
  async function loadImageManifest() {
416
515
  try {
417
516
  const manifestRaw = await readFile(join(distDir, '_zenith', 'image', 'manifest.json'), 'utf8');
@@ -422,24 +521,17 @@ export async function createPreviewServer(options) {
422
521
  return {};
423
522
  }
424
523
  }
425
- function publicHost() {
426
- if (host === '0.0.0.0' || host === '::') {
427
- return '127.0.0.1';
428
- }
429
- return host;
430
- }
431
- function serverOrigin() {
432
- return `http://${publicHost()}:${actualPort}`;
433
- }
434
524
  const server = createServer(async (req, res) => {
435
- const requestBase = typeof req.headers.host === 'string' && req.headers.host.length > 0
436
- ? `http://${req.headers.host}`
437
- : serverOrigin();
438
- const url = new URL(req.url, requestBase);
439
- const { basePath, routes } = await loadRouteManifestState(distDir, configuredBasePath);
525
+ const url = new URL(req.url, resolveServerOrigin());
526
+ const { basePath, pageRoutes, resourceRoutes } = await loadRouteSurfaceState(distDir, configuredBasePath);
440
527
  const canonicalPath = stripBasePath(url.pathname, basePath);
441
528
  try {
442
529
  if (url.pathname === routeCheckPath(basePath)) {
530
+ if (!routeCheckEnabled) {
531
+ res.writeHead(501, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
532
+ res.end(JSON.stringify({ error: 'route_check_unsupported' }));
533
+ return;
534
+ }
443
535
  // Security: Require explicitly designated header to prevent public oracle probing
444
536
  if (req.headers['x-zenith-route-check'] !== '1') {
445
537
  res.writeHead(403, { 'Content-Type': 'application/json' });
@@ -467,7 +559,7 @@ export async function createPreviewServer(options) {
467
559
  }
468
560
  const canonicalTargetUrl = new URL(targetUrl.toString());
469
561
  canonicalTargetUrl.pathname = canonicalTargetPath;
470
- const resolvedCheck = resolveRequestRoute(canonicalTargetUrl, routes);
562
+ const resolvedCheck = resolveRequestRoute(canonicalTargetUrl, pageRoutes);
471
563
  if (!resolvedCheck.matched || !resolvedCheck.route) {
472
564
  res.writeHead(404, { 'Content-Type': 'application/json' });
473
565
  res.end(JSON.stringify({ error: 'route_not_found' }));
@@ -509,13 +601,16 @@ export async function createPreviewServer(options) {
509
601
  'Vary': 'Cookie'
510
602
  });
511
603
  res.end(JSON.stringify({
512
- result: checkResult?.result || checkResult,
604
+ result: sanitizeRouteResult(checkResult?.result || checkResult),
513
605
  routeId: resolvedCheck.route.route_id || '',
514
606
  to: targetUrl.toString()
515
607
  }));
516
608
  return;
517
609
  }
518
610
  if (url.pathname === imageEndpointPath(basePath)) {
611
+ if (isStaticExportTarget) {
612
+ throw new Error('not found');
613
+ }
519
614
  await handleImageRequest(req, res, {
520
615
  requestUrl: url,
521
616
  projectRoot,
@@ -527,7 +622,9 @@ export async function createPreviewServer(options) {
527
622
  throw new Error('not found');
528
623
  }
529
624
  if (extname(canonicalPath) && extname(canonicalPath) !== '.html') {
530
- const staticPath = resolveWithinDist(distDir, canonicalPath);
625
+ const staticPath = isStaticExportTarget
626
+ ? resolveWithinDist(distDir, url.pathname)
627
+ : resolveWithinDist(distDir, canonicalPath);
531
628
  if (!staticPath || !(await fileExists(staticPath))) {
532
629
  throw new Error('not found');
533
630
  }
@@ -537,9 +634,47 @@ export async function createPreviewServer(options) {
537
634
  res.end(content);
538
635
  return;
539
636
  }
637
+ if (isStaticExportTarget) {
638
+ const directHtmlPath = toStaticFilePath(distDir, url.pathname);
639
+ if (!directHtmlPath || !(await fileExists(directHtmlPath))) {
640
+ throw new Error('not found');
641
+ }
642
+ const html = await readFile(directHtmlPath, 'utf8');
643
+ res.writeHead(200, { 'Content-Type': 'text/html' });
644
+ res.end(html);
645
+ return;
646
+ }
540
647
  const canonicalUrl = new URL(url.toString());
541
648
  canonicalUrl.pathname = canonicalPath;
542
- const resolved = resolveRequestRoute(canonicalUrl, routes);
649
+ const resolvedResource = resolveRequestRoute(canonicalUrl, resourceRoutes);
650
+ if (resolvedResource.matched && resolvedResource.route) {
651
+ const requestMethod = req.method || 'GET';
652
+ const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
653
+ ? null
654
+ : await readRequestBodyBuffer(req);
655
+ const execution = await executeServerRoute({
656
+ source: resolvedResource.route.server_script || '',
657
+ sourcePath: resolvedResource.route.server_script_path || '',
658
+ params: resolvedResource.params,
659
+ requestUrl: url.toString(),
660
+ requestMethod,
661
+ requestHeaders: req.headers,
662
+ requestBodyBuffer,
663
+ routePattern: resolvedResource.route.path,
664
+ routeFile: resolvedResource.route.server_script_path || '',
665
+ routeId: resolvedResource.route.route_id || routeIdFromSourcePath(resolvedResource.route.server_script_path || ''),
666
+ routeKind: 'resource'
667
+ });
668
+ const descriptor = buildResourceResponseDescriptor(execution?.result, basePath, Array.isArray(execution?.setCookies) ? execution.setCookies : []);
669
+ res.writeHead(descriptor.status, appendSetCookieHeaders(descriptor.headers, descriptor.setCookies));
670
+ if ((req.method || 'GET').toUpperCase() === 'HEAD') {
671
+ res.end();
672
+ return;
673
+ }
674
+ res.end(descriptor.body);
675
+ return;
676
+ }
677
+ const resolved = resolveRequestRoute(canonicalUrl, pageRoutes);
543
678
  let htmlPath = null;
544
679
  if (resolved.matched && resolved.route) {
545
680
  if (verboseLogging) {
@@ -557,48 +692,56 @@ export async function createPreviewServer(options) {
557
692
  throw new Error('not found');
558
693
  }
559
694
  let ssrPayload = null;
695
+ let routeExecution = null;
560
696
  if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
561
- let routeExecution = null;
562
697
  try {
698
+ const requestMethod = req.method || 'GET';
699
+ const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
700
+ ? null
701
+ : await readRequestBodyBuffer(req);
563
702
  routeExecution = await executeServerRoute({
564
703
  source: resolved.route.server_script,
565
704
  sourcePath: resolved.route.server_script_path || '',
566
705
  params: resolved.params,
567
706
  requestUrl: url.toString(),
568
- requestMethod: req.method || 'GET',
707
+ requestMethod,
569
708
  requestHeaders: req.headers,
709
+ requestBodyBuffer,
570
710
  routePattern: resolved.route.path,
571
711
  routeFile: resolved.route.server_script_path || '',
572
712
  routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
573
713
  });
574
714
  }
575
715
  catch (error) {
716
+ logServerException('preview server route execution failed', error);
576
717
  ssrPayload = {
577
718
  __zenith_error: {
719
+ status: 500,
578
720
  code: 'LOAD_FAILED',
579
- message: error instanceof Error ? error.message : String(error)
721
+ message: error instanceof Error ? error.message : String(error || '')
580
722
  }
581
723
  };
582
724
  }
583
- const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
725
+ const trace = routeExecution?.trace || { guard: 'none', action: 'none', load: 'none' };
584
726
  const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
727
+ const setCookies = Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : [];
585
728
  if (verboseLogging) {
586
- logger.router(`${routeId} guard=${trace.guard} load=${trace.load}`);
729
+ logger.router(`${routeId} guard=${trace.guard} action=${trace.action} load=${trace.load}`);
587
730
  }
588
731
  const result = routeExecution?.result;
589
732
  if (result && result.kind === 'redirect') {
590
733
  const status = Number.isInteger(result.status) ? result.status : 302;
591
- res.writeHead(status, {
734
+ res.writeHead(status, appendSetCookieHeaders({
592
735
  Location: appLocalRedirectLocation(result.location, basePath),
593
736
  'Cache-Control': 'no-store'
594
- });
737
+ }, setCookies));
595
738
  res.end('');
596
739
  return;
597
740
  }
598
741
  if (result && result.kind === 'deny') {
599
742
  const status = Number.isInteger(result.status) ? result.status : 403;
600
- res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
601
- res.end(result.message || defaultRouteDenyMessage(status));
743
+ res.writeHead(status, appendSetCookieHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }, setCookies));
744
+ res.end(clientFacingRouteMessage(status, result.message));
602
745
  return;
603
746
  }
604
747
  if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
@@ -606,21 +749,24 @@ export async function createPreviewServer(options) {
606
749
  }
607
750
  }
608
751
  let html = await readFile(htmlPath, 'utf8');
609
- if (resolved.matched && resolved.route?.page_asset) {
610
- const pageAssetPath = resolveWithinDist(distDir, resolved.route.page_asset);
752
+ if (resolved.matched) {
611
753
  html = await materializeImageMarkup({
612
754
  html,
613
- pageAssetPath,
614
755
  payload: createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint', basePath),
615
- ssrData: ssrPayload,
616
- routePathname: url.pathname
756
+ imageMaterialization: Array.isArray(resolved.route?.image_materialization)
757
+ ? resolved.route.image_materialization
758
+ : []
617
759
  });
618
760
  }
619
761
  if (ssrPayload) {
620
762
  html = injectSsrPayload(html, ssrPayload);
621
763
  }
622
- html = injectImageRuntimePayload(html, createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint', basePath));
623
- res.writeHead(200, { 'Content-Type': 'text/html' });
764
+ if (!IMAGE_RUNTIME_TAG_RE.test(html)) {
765
+ html = injectImageRuntimePayload(html, createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint', basePath));
766
+ }
767
+ res.writeHead(Number.isInteger(routeExecution?.status) ? routeExecution.status : 200, appendSetCookieHeaders({
768
+ 'Content-Type': 'text/html'
769
+ }, Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : []));
624
770
  res.end(html);
625
771
  }
626
772
  catch {
@@ -662,42 +808,46 @@ export async function createPreviewServer(options) {
662
808
  * @returns {Promise<PreviewRoute[]>}
663
809
  */
664
810
  export async function loadRouteManifest(distDir) {
665
- const state = await loadRouteManifestState(distDir, '/');
666
- return state.routes;
811
+ const state = await loadRouteSurfaceState(distDir, '/');
812
+ return state.pageRoutes;
667
813
  }
668
- async function loadRouteManifestState(distDir, fallbackBasePath = '/') {
814
+ export async function loadRouteSurfaceState(distDir, fallbackBasePath = '/') {
669
815
  const manifestPath = join(distDir, 'assets', 'router-manifest.json');
816
+ const resourceState = await loadResourceRouteManifest(distDir, normalizeBasePath(fallbackBasePath || '/'));
670
817
  try {
671
818
  const source = await readFile(manifestPath, 'utf8');
672
819
  const parsed = JSON.parse(source);
673
820
  const routes = Array.isArray(parsed?.routes) ? parsed.routes : [];
821
+ const basePath = normalizeBasePath(parsed?.base_path || resourceState.basePath || fallbackBasePath || '/');
674
822
  return {
675
- basePath: normalizeBasePath(parsed?.base_path || fallbackBasePath || '/'),
676
- routes: routes
823
+ basePath,
824
+ pageRoutes: routes
677
825
  .filter((entry) => entry &&
678
826
  typeof entry === 'object' &&
679
827
  typeof entry.path === 'string' &&
680
828
  typeof entry.output === 'string')
681
- .sort((a, b) => compareRouteSpecificity(a.path, b.path))
829
+ .sort((a, b) => compareRouteSpecificity(a.path, b.path)),
830
+ resourceRoutes: Array.isArray(resourceState.routes) ? resourceState.routes : []
682
831
  };
683
832
  }
684
833
  catch {
685
834
  return {
686
- basePath: normalizeBasePath(fallbackBasePath || '/'),
687
- routes: []
835
+ basePath: normalizeBasePath(resourceState.basePath || fallbackBasePath || '/'),
836
+ pageRoutes: [],
837
+ resourceRoutes: Array.isArray(resourceState.routes) ? resourceState.routes : []
688
838
  };
689
839
  }
690
840
  }
691
841
  export const matchRoute = matchManifestRoute;
692
842
  /**
693
- * @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
694
- * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, load: string } }>}
843
+ * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl?: string, requestMethod?: string, requestHeaders?: Record<string, string | string[] | undefined>, requestBodyBuffer?: Buffer | null, routePattern?: string, routeFile?: string, routeId?: string, routeKind?: 'page' | 'resource' }} input
844
+ * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, action: string, load: string }, status?: number, setCookies?: string[] }>}
695
845
  */
696
- export async function executeServerRoute({ source, sourcePath, params, requestUrl, requestMethod, requestHeaders, routePattern, routeFile, routeId, guardOnly = false }) {
846
+ export async function executeServerRoute({ source, sourcePath, params, requestUrl, requestMethod, requestHeaders, requestBodyBuffer, routePattern, routeFile, routeId, routeKind = 'page', guardOnly = false }) {
697
847
  if (!source || !String(source).trim()) {
698
848
  return {
699
849
  result: { kind: 'data', data: {} },
700
- trace: { guard: 'none', load: 'none' }
850
+ trace: { guard: 'none', action: 'none', load: 'none' }
701
851
  };
702
852
  }
703
853
  const payload = await spawnNodeServerRunner({
@@ -707,15 +857,17 @@ export async function executeServerRoute({ source, sourcePath, params, requestUr
707
857
  requestUrl: requestUrl || 'http://localhost/',
708
858
  requestMethod: requestMethod || 'GET',
709
859
  requestHeaders: sanitizeRequestHeaders(requestHeaders || {}),
860
+ requestBodyBuffer: Buffer.isBuffer(requestBodyBuffer) ? requestBodyBuffer : null,
710
861
  routePattern: routePattern || '',
711
862
  routeFile: routeFile || sourcePath || '',
712
863
  routeId: routeId || routeIdFromSourcePath(sourcePath || ''),
864
+ routeKind,
713
865
  guardOnly
714
866
  });
715
867
  if (payload === null || payload === undefined) {
716
868
  return {
717
869
  result: { kind: 'data', data: {} },
718
- trace: { guard: 'none', load: 'none' }
870
+ trace: { guard: 'none', action: 'none', load: 'none' }
719
871
  };
720
872
  }
721
873
  if (typeof payload !== 'object' || Array.isArray(payload)) {
@@ -727,9 +879,9 @@ export async function executeServerRoute({ source, sourcePath, params, requestUr
727
879
  result: {
728
880
  kind: 'deny',
729
881
  status: 500,
730
- message: String(errorEnvelope.message || 'Server route execution failed')
882
+ message: defaultRouteDenyMessage(500)
731
883
  },
732
- trace: { guard: 'none', load: 'deny' }
884
+ trace: { guard: 'none', action: 'none', load: 'deny' }
733
885
  };
734
886
  }
735
887
  const result = payload.result;
@@ -740,9 +892,14 @@ export async function executeServerRoute({ source, sourcePath, params, requestUr
740
892
  trace: trace && typeof trace === 'object'
741
893
  ? {
742
894
  guard: String(trace.guard || 'none'),
895
+ action: String(trace.action || 'none'),
743
896
  load: String(trace.load || 'none')
744
897
  }
745
- : { guard: 'none', load: 'none' }
898
+ : { guard: 'none', action: 'none', load: 'none' },
899
+ status: Number.isInteger(payload.status) ? payload.status : undefined,
900
+ setCookies: Array.isArray(payload.setCookies)
901
+ ? payload.setCookies.filter((value) => typeof value === 'string' && value.length > 0)
902
+ : []
746
903
  };
747
904
  }
748
905
  return {
@@ -750,18 +907,9 @@ export async function executeServerRoute({ source, sourcePath, params, requestUr
750
907
  kind: 'data',
751
908
  data: payload
752
909
  },
753
- trace: { guard: 'none', load: 'data' }
910
+ trace: { guard: 'none', action: 'none', load: 'data' }
754
911
  };
755
912
  }
756
- export function defaultRouteDenyMessage(status) {
757
- if (status === 401)
758
- return 'Unauthorized';
759
- if (status === 403)
760
- return 'Forbidden';
761
- if (status === 404)
762
- return 'Not Found';
763
- return 'Internal Server Error';
764
- }
765
913
  /**
766
914
  * @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
767
915
  * @returns {Promise<Record<string, unknown> | null>}
@@ -790,14 +938,14 @@ export async function executeServerScript(input) {
790
938
  __zenith_error: {
791
939
  status,
792
940
  code: status >= 500 ? 'LOAD_FAILED' : (status === 404 ? 'NOT_FOUND' : 'ACCESS_DENIED'),
793
- message: String(result.message || defaultRouteDenyMessage(status))
941
+ message: clientFacingRouteMessage(status, result.message)
794
942
  }
795
943
  };
796
944
  }
797
945
  return {};
798
946
  }
799
947
  /**
800
- * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl: string, requestMethod: string, requestHeaders: Record<string, string>, routePattern: string, routeFile: string, routeId: string }} input
948
+ * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl: string, requestMethod: string, requestHeaders: Record<string, string>, requestBodyBuffer?: Buffer | null, routePattern: string, routeFile: string, routeId: string, routeKind?: 'page' | 'resource' }} input
801
949
  * @returns {Promise<unknown>}
802
950
  */
803
951
  function spawnNodeServerRunner(input) {
@@ -814,11 +962,18 @@ function spawnNodeServerRunner(input) {
814
962
  ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
815
963
  ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
816
964
  ZENITH_SERVER_ROUTE_ID: input.routeId || '',
965
+ ZENITH_SERVER_ROUTE_KIND: input.routeKind || 'page',
817
966
  ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
818
- ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
967
+ ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js'),
968
+ ZENITH_SERVER_ROUTE_AUTH_PATH: join(__dirname, 'auth', 'route-auth.js')
819
969
  },
820
- stdio: ['ignore', 'pipe', 'pipe']
970
+ stdio: ['pipe', 'pipe', 'pipe']
971
+ });
972
+ const runnerRequestBody = Buffer.isBuffer(input.requestBodyBuffer) ? input.requestBodyBuffer : null;
973
+ child.stdin.on('error', () => {
974
+ // ignore broken pipes when the runner exits before consuming stdin
821
975
  });
976
+ child.stdin.end(runnerRequestBody && runnerRequestBody.length > 0 ? runnerRequestBody : undefined);
822
977
  let stdout = '';
823
978
  let stderr = '';
824
979
  child.stdout.on('data', (chunk) => {
@@ -835,6 +990,11 @@ function spawnNodeServerRunner(input) {
835
990
  rejectPromise(new Error(`[zenith-preview] server script execution failed (${code}): ${stderr.trim() || stdout.trim()}`));
836
991
  return;
837
992
  }
993
+ const stderrOutput = stderr.trim();
994
+ const internalErrorIndex = stderrOutput.indexOf('[Zenith:Server]');
995
+ if (internalErrorIndex >= 0) {
996
+ console.error(stderrOutput.slice(internalErrorIndex).trim());
997
+ }
838
998
  const raw = stdout.trim();
839
999
  if (!raw || raw === 'null') {
840
1000
  resolvePromise(null);