@zenithbuild/cli 0.5.0-beta.2.6 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10,11 +10,62 @@
10
10
  // Minimal arg parsing. No heavy dependencies.
11
11
  // ---------------------------------------------------------------------------
12
12
 
13
- import { resolve, join } from 'node:path';
14
- import { existsSync } from 'node:fs';
15
- import { createLogger } from './ui/logger.js';
13
+ import { resolve, join, dirname } from 'node:path';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { createZenithLogger } from './ui/logger.js';
16
17
 
17
18
  const COMMANDS = ['dev', 'build', 'preview'];
19
+ const DEFAULT_VERSION = '0.0.0';
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+
23
+ function getCliVersion() {
24
+ try {
25
+ const pkgPath = join(__dirname, '..', 'package.json');
26
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
27
+ return typeof pkg.version === 'string' ? pkg.version : DEFAULT_VERSION;
28
+ } catch {
29
+ return DEFAULT_VERSION;
30
+ }
31
+ }
32
+
33
+ function printUsage(logger) {
34
+ logger.heading('V0');
35
+ logger.print('Usage:');
36
+ logger.print(' zenith dev [port|--port <port>] Start development server');
37
+ logger.print(' zenith build Build static site to /dist');
38
+ logger.print(' zenith preview [port|--port <port>] Preview /dist statically');
39
+ logger.print('');
40
+ logger.print('Options:');
41
+ logger.print(' -h, --help Show this help message');
42
+ logger.print(' -v, --version Print Zenith CLI version');
43
+ logger.print('');
44
+ }
45
+
46
+ function resolvePort(args, fallback) {
47
+ if (!Array.isArray(args) || args.length === 0) {
48
+ return fallback;
49
+ }
50
+
51
+ const flagIndex = args.findIndex((arg) => arg === '--port' || arg === '-p');
52
+ if (flagIndex >= 0 && args[flagIndex + 1]) {
53
+ const parsed = Number.parseInt(args[flagIndex + 1], 10);
54
+ if (Number.isFinite(parsed)) {
55
+ return parsed;
56
+ }
57
+ }
58
+
59
+ const positional = args.find((arg) => /^[0-9]+$/.test(arg));
60
+ if (positional) {
61
+ const parsed = Number.parseInt(positional, 10);
62
+ if (Number.isFinite(parsed)) {
63
+ return parsed;
64
+ }
65
+ }
66
+
67
+ return fallback;
68
+ }
18
69
 
19
70
  /**
20
71
  * Load zenith.config.js from project root.
@@ -39,16 +90,22 @@ async function loadConfig(projectRoot) {
39
90
  * @param {string} [cwd] - Working directory override
40
91
  */
41
92
  export async function cli(args, cwd) {
42
- const logger = createLogger(process);
93
+ const logger = createZenithLogger(process);
43
94
  const command = args[0];
95
+ const cliVersion = getCliVersion();
96
+
97
+ if (args.includes('--version') || args.includes('-v')) {
98
+ logger.print(`zenith ${cliVersion}`);
99
+ process.exit(0);
100
+ }
101
+
102
+ if (args.includes('--help') || args.includes('-h')) {
103
+ printUsage(logger);
104
+ process.exit(0);
105
+ }
44
106
 
45
107
  if (!command || !COMMANDS.includes(command)) {
46
- logger.heading('V0');
47
- logger.print('Usage:');
48
- logger.print(' zenith dev Start development server');
49
- logger.print(' zenith build Build static site to /dist');
50
- logger.print(' zenith preview Preview /dist statically');
51
- logger.print('');
108
+ printUsage(logger);
52
109
  process.exit(command ? 1 : 0);
53
110
  }
54
111
 
@@ -61,18 +118,21 @@ export async function cli(args, cwd) {
61
118
 
62
119
  if (command === 'build') {
63
120
  const { build } = await import('./build.js');
64
- logger.info('Building...');
65
- const result = await build({ pagesDir, outDir, config });
66
- logger.success(`Built ${result.pages} page(s), ${result.assets.length} asset(s)`);
67
- logger.summary([{ label: 'Output', value: './dist' }]);
121
+ logger.build('Building');
122
+ const result = await build({ pagesDir, outDir, config, logger, showBundlerInfo: false });
123
+ logger.ok(`Built ${result.pages} page(s), ${result.assets.length} asset(s)`);
124
+ logger.summary([{ label: 'Output', value: './dist' }], 'BUILD');
68
125
  }
69
126
 
70
127
  if (command === 'dev') {
71
128
  const { createDevServer } = await import('./dev-server.js');
72
- const port = parseInt(args[1]) || 3000;
73
- logger.info('Starting dev server...');
74
- const dev = await createDevServer({ pagesDir, outDir, port, config });
75
- logger.success(`Dev server running at http://localhost:${dev.port}`);
129
+ const port = process.env.ZENITH_DEV_PORT
130
+ ? Number.parseInt(process.env.ZENITH_DEV_PORT, 10)
131
+ : resolvePort(args.slice(1), 3000);
132
+ const host = process.env.ZENITH_DEV_HOST || '127.0.0.1';
133
+ logger.dev('Starting dev server…');
134
+ const dev = await createDevServer({ pagesDir, outDir, port, host, config, logger });
135
+ logger.ok(`http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${dev.port}`);
76
136
 
77
137
  // Graceful shutdown
78
138
  process.on('SIGINT', () => {
@@ -87,10 +147,11 @@ export async function cli(args, cwd) {
87
147
 
88
148
  if (command === 'preview') {
89
149
  const { createPreviewServer } = await import('./preview.js');
90
- const port = parseInt(args[1]) || 4000;
91
- logger.info('Starting preview server...');
92
- const preview = await createPreviewServer({ distDir: outDir, port });
93
- logger.success(`Preview server running at http://localhost:${preview.port}`);
150
+ const port = resolvePort(args.slice(1), 4000);
151
+ const host = process.env.ZENITH_PREVIEW_HOST || '127.0.0.1';
152
+ logger.dev('Starting preview server…');
153
+ const preview = await createPreviewServer({ distDir: outDir, port, host, logger });
154
+ logger.ok(`http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${preview.port}`);
94
155
 
95
156
  process.on('SIGINT', () => {
96
157
  preview.close();
@@ -111,7 +172,7 @@ const isDirectRun = process.argv[1] && (
111
172
 
112
173
  if (isDirectRun) {
113
174
  cli(process.argv.slice(2)).catch((error) => {
114
- const logger = createLogger(process);
175
+ const logger = createZenithLogger(process);
115
176
  logger.error(error);
116
177
  process.exit(1);
117
178
  });
package/dist/preview.js CHANGED
@@ -14,6 +14,7 @@ import { createServer } from 'node:http';
14
14
  import { access, readFile } from 'node:fs/promises';
15
15
  import { extname, join, normalize, resolve, sep, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { createSilentLogger } from './ui/logger.js';
17
18
  import {
18
19
  compareRouteSpecificity,
19
20
  matchRoute as matchManifestRoute,
@@ -47,6 +48,7 @@ const requestHeaders = JSON.parse(process.env.ZENITH_SERVER_REQUEST_HEADERS || '
47
48
  const routePattern = process.env.ZENITH_SERVER_ROUTE_PATTERN || '';
48
49
  const routeFile = process.env.ZENITH_SERVER_ROUTE_FILE || sourcePath || '';
49
50
  const routeId = process.env.ZENITH_SERVER_ROUTE_ID || routePattern || '';
51
+ const guardOnly = process.env.ZENITH_SERVER_GUARD_ONLY === '1';
50
52
 
51
53
  if (!source.trim()) {
52
54
  process.stdout.write('null');
@@ -158,6 +160,55 @@ const safeRequestHeaders =
158
160
  requestHeaders && typeof requestHeaders === 'object'
159
161
  ? { ...requestHeaders }
160
162
  : {};
163
+ function parseCookies(rawCookieHeader) {
164
+ const out = Object.create(null);
165
+ const raw = String(rawCookieHeader || '');
166
+ if (!raw) return out;
167
+ const pairs = raw.split(';');
168
+ for (let i = 0; i < pairs.length; i++) {
169
+ const part = pairs[i];
170
+ const eq = part.indexOf('=');
171
+ if (eq <= 0) continue;
172
+ const key = part.slice(0, eq).trim();
173
+ if (!key) continue;
174
+ const value = part.slice(eq + 1).trim();
175
+ try {
176
+ out[key] = decodeURIComponent(value);
177
+ } catch {
178
+ out[key] = value;
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+ const cookieHeader = typeof safeRequestHeaders.cookie === 'string'
184
+ ? safeRequestHeaders.cookie
185
+ : '';
186
+ const requestCookies = parseCookies(cookieHeader);
187
+
188
+ function ctxAllow() {
189
+ return { kind: 'allow' };
190
+ }
191
+ function ctxRedirect(location, status = 302) {
192
+ return {
193
+ kind: 'redirect',
194
+ location: String(location || ''),
195
+ status: Number.isInteger(status) ? status : 302
196
+ };
197
+ }
198
+ function ctxDeny(status = 403, message = undefined) {
199
+ return {
200
+ kind: 'deny',
201
+ status: Number.isInteger(status) ? status : 403,
202
+ message: typeof message === 'string' ? message : undefined
203
+ };
204
+ }
205
+ function ctxData(payload) {
206
+ return {
207
+ kind: 'data',
208
+ data: payload
209
+ };
210
+ }
211
+
161
212
  const requestSnapshot = new Request(requestUrl, {
162
213
  method: requestMethod,
163
214
  headers: new Headers(safeRequestHeaders)
@@ -168,15 +219,32 @@ const routeMeta = {
168
219
  pattern: routePattern,
169
220
  file: routeFile ? path.relative(process.cwd(), routeFile) : ''
170
221
  };
222
+ const routeContext = {
223
+ params: routeParams,
224
+ url: new URL(requestUrl),
225
+ headers: { ...safeRequestHeaders },
226
+ cookies: requestCookies,
227
+ request: requestSnapshot,
228
+ method: requestMethod,
229
+ route: routeMeta,
230
+ env: {},
231
+ auth: {
232
+ async getSession(_ctx) {
233
+ return null;
234
+ },
235
+ async requireSession(_ctx) {
236
+ throw ctxRedirect('/login', 302);
237
+ }
238
+ },
239
+ allow: ctxAllow,
240
+ redirect: ctxRedirect,
241
+ deny: ctxDeny,
242
+ data: ctxData
243
+ };
171
244
 
172
245
  const context = vm.createContext({
173
246
  params: routeParams,
174
- ctx: {
175
- params: routeParams,
176
- url: new URL(requestUrl),
177
- request: requestSnapshot,
178
- route: routeMeta
179
- },
247
+ ctx: routeContext,
180
248
  fetch: globalThis.fetch,
181
249
  Headers: globalThis.Headers,
182
250
  Request: globalThis.Request,
@@ -251,11 +319,11 @@ async function linkModule(specifier, parentIdentifier) {
251
319
  return loadFileModule(resolvedUrl);
252
320
  }
253
321
 
254
- const allowed = new Set(['data', 'load', 'ssr_data', 'props', 'ssr', 'prerender']);
322
+ const allowed = new Set(['data', 'load', 'guard', 'ssr_data', 'props', 'ssr', 'prerender']);
255
323
  const prelude = "const params = globalThis.params;\n" +
256
324
  "const ctx = globalThis.ctx;\n" +
257
- "import { resolveServerPayload } from 'zenith:server-contract';\n" +
258
- "globalThis.resolveServerPayload = resolveServerPayload;\n";
325
+ "import { resolveRouteResult } from 'zenith:server-contract';\n" +
326
+ "globalThis.resolveRouteResult = resolveRouteResult;\n";
259
327
  const entryIdentifier = sourcePath
260
328
  ? pathToFileURL(sourcePath).href
261
329
  : 'zenith:server-script';
@@ -294,13 +362,14 @@ for (const key of namespaceKeys) {
294
362
 
295
363
  const exported = entryModule.namespace;
296
364
  try {
297
- const payload = await context.resolveServerPayload({
365
+ const resolved = await context.resolveRouteResult({
298
366
  exports: exported,
299
367
  ctx: context.ctx,
300
- filePath: sourcePath || 'server_script'
368
+ filePath: sourcePath || 'server_script',
369
+ guardOnly: guardOnly
301
370
  });
302
371
 
303
- process.stdout.write(JSON.stringify(payload === undefined ? null : payload));
372
+ process.stdout.write(JSON.stringify(resolved || null));
304
373
  } catch (error) {
305
374
  const message = error instanceof Error ? error.message : String(error);
306
375
  process.stdout.write(
@@ -318,17 +387,108 @@ try {
318
387
  /**
319
388
  * Create and start a preview server.
320
389
  *
321
- * @param {{ distDir: string, port?: number }} options
390
+ * @param {{ distDir: string, port?: number, host?: string, logger?: object | null }} options
322
391
  * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
323
392
  */
324
393
  export async function createPreviewServer(options) {
325
- const { distDir, port = 4000 } = 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
+ }
326
409
 
327
410
  const server = createServer(async (req, res) => {
328
- const url = new URL(req.url, `http://localhost:${port}`);
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);
329
415
 
330
416
  try {
331
- if (extname(url.pathname)) {
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
+ }
474
+ }
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') {
332
492
  const staticPath = resolveWithinDist(distDir, url.pathname);
333
493
  if (!staticPath || !(await fileExists(staticPath))) {
334
494
  throw new Error('not found');
@@ -345,7 +505,11 @@ export async function createPreviewServer(options) {
345
505
  let htmlPath = null;
346
506
 
347
507
  if (resolved.matched && resolved.route) {
348
- console.log(`[zenith] Request: ${url.pathname} | Route: ${resolved.route.path} | Params: ${JSON.stringify(resolved.params)}`);
508
+ if (verboseLogging) {
509
+ logger.router(
510
+ `${req.method || 'GET'} ${url.pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`
511
+ );
512
+ }
349
513
  const output = resolved.route.output.startsWith('/')
350
514
  ? resolved.route.output.slice(1)
351
515
  : resolved.route.output;
@@ -358,11 +522,11 @@ export async function createPreviewServer(options) {
358
522
  throw new Error('not found');
359
523
  }
360
524
 
361
- let html = await readFile(htmlPath, 'utf8');
525
+ let ssrPayload = null;
362
526
  if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
363
- let payload = null;
527
+ let routeExecution = null;
364
528
  try {
365
- payload = await executeServerScript({
529
+ routeExecution = await executeServerRoute({
366
530
  source: resolved.route.server_script,
367
531
  sourcePath: resolved.route.server_script_path || '',
368
532
  params: resolved.params,
@@ -371,22 +535,49 @@ export async function createPreviewServer(options) {
371
535
  requestHeaders: req.headers,
372
536
  routePattern: resolved.route.path,
373
537
  routeFile: resolved.route.server_script_path || '',
374
- routeId: routeIdFromSourcePath(resolved.route.server_script_path || '')
538
+ routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
375
539
  });
376
540
  } catch (error) {
377
- payload = {
541
+ ssrPayload = {
378
542
  __zenith_error: {
379
- status: 500,
380
543
  code: 'LOAD_FAILED',
381
544
  message: error instanceof Error ? error.message : String(error)
382
545
  }
383
546
  };
384
547
  }
385
- if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
386
- html = injectSsrPayload(html, payload);
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;
387
573
  }
388
574
  }
389
575
 
576
+ let html = await readFile(htmlPath, 'utf8');
577
+ if (ssrPayload) {
578
+ html = injectSsrPayload(html, ssrPayload);
579
+ }
580
+
390
581
  res.writeHead(200, { 'Content-Type': 'text/html' });
391
582
  res.end(html);
392
583
  } catch {
@@ -396,8 +587,8 @@ export async function createPreviewServer(options) {
396
587
  });
397
588
 
398
589
  return new Promise((resolveServer) => {
399
- server.listen(port, () => {
400
- const actualPort = server.address().port;
590
+ server.listen(port, host, () => {
591
+ actualPort = server.address().port;
401
592
  resolveServer({
402
593
  server,
403
594
  port: actualPort,
@@ -416,6 +607,13 @@ export async function createPreviewServer(options) {
416
607
  * server_script?: string | null;
417
608
  * server_script_path?: string | null;
418
609
  * prerender?: boolean;
610
+ * route_id?: string;
611
+ * pattern?: string;
612
+ * params_shape?: Record<string, string>;
613
+ * has_guard?: boolean;
614
+ * has_load?: boolean;
615
+ * guard_module_ref?: string | null;
616
+ * load_module_ref?: string | null;
419
617
  * }} PreviewRoute
420
618
  */
421
619
 
@@ -446,28 +644,120 @@ export const matchRoute = matchManifestRoute;
446
644
 
447
645
  /**
448
646
  * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl?: string, requestMethod?: string, requestHeaders?: Record<string, string | string[] | undefined>, routePattern?: string, routeFile?: string, routeId?: string }} input
449
- * @returns {Promise<Record<string, unknown> | null>}
647
+ * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, load: string } }>}
450
648
  */
451
- export async function executeServerScript(input) {
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
+
452
668
  const payload = await spawnNodeServerRunner({
453
- source: input.source,
454
- sourcePath: input.sourcePath,
455
- params: input.params,
456
- requestUrl: input.requestUrl || 'http://localhost/',
457
- requestMethod: input.requestMethod || 'GET',
458
- requestHeaders: sanitizeRequestHeaders(input.requestHeaders || {}),
459
- routePattern: input.routePattern || '',
460
- routeFile: input.routeFile || input.sourcePath || '',
461
- routeId: input.routeId || routeIdFromSourcePath(input.sourcePath || '')
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
462
679
  });
463
680
 
464
681
  if (payload === null || payload === undefined) {
465
- return null;
682
+ return {
683
+ result: { kind: 'data', data: {} },
684
+ trace: { guard: 'none', load: 'none' }
685
+ };
466
686
  }
467
687
  if (typeof payload !== 'object' || Array.isArray(payload)) {
468
688
  throw new Error('[zenith-preview] server script payload must be an object');
469
689
  }
470
- return payload;
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') {
706
+ 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' }
714
+ };
715
+ }
716
+
717
+ return {
718
+ result: {
719
+ kind: 'data',
720
+ data: payload
721
+ },
722
+ trace: { guard: 'none', load: 'data' }
723
+ };
724
+ }
725
+
726
+ /**
727
+ * @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
+ * @returns {Promise<Record<string, unknown> | null>}
729
+ */
730
+ 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 {};
471
761
  }
472
762
 
473
763
  /**
@@ -491,6 +781,7 @@ function spawnNodeServerRunner(input) {
491
781
  ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
492
782
  ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
493
783
  ZENITH_SERVER_ROUTE_ID: input.routeId || '',
784
+ ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
494
785
  ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
495
786
  },
496
787
  stdio: ['ignore', 'pipe', 'pipe']
@@ -609,7 +900,7 @@ export function resolveWithinDist(distDir, requestPath) {
609
900
  */
610
901
  function sanitizeRequestHeaders(headers) {
611
902
  const out = Object.create(null);
612
- const denyExact = new Set(['authorization', 'cookie', 'proxy-authorization', 'set-cookie']);
903
+ const denyExact = new Set(['proxy-authorization', 'set-cookie']);
613
904
  const denyPrefixes = ['x-forwarded-', 'cf-'];
614
905
  for (const [rawKey, rawValue] of Object.entries(headers || {})) {
615
906
  const key = String(rawKey || '').toLowerCase();