@zenithbuild/cli 0.5.0-beta.2.12 → 0.5.0-beta.2.16

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/README.md CHANGED
@@ -5,6 +5,11 @@
5
5
 
6
6
  The command-line interface for developing and building Zenith applications.
7
7
 
8
+ ## Canonical Docs
9
+
10
+ - CLI contract: `../zenith-docs/documentation/cli-contract.md`
11
+ - Script server/data contract: `../zenith-docs/documentation/contracts/server-data.md`
12
+
8
13
  ## Overview
9
14
 
10
15
  `@zenithbuild/cli` provides the toolchain needed to manage Zenith projects. While `create-zenith` is for scaffolding, this CLI is for the daily development loop: serving apps, building for production, and managing plugins.
package/dist/build.js CHANGED
@@ -68,6 +68,24 @@ const BUNDLER_BIN = resolveBinary([
68
68
  resolve(CLI_ROOT, '../zenith-bundler/target/release/zenith-bundler')
69
69
  ]);
70
70
 
71
+ /**
72
+ * Build a per-build warning emitter that deduplicates repeated compiler lines.
73
+ *
74
+ * @param {(line: string) => void} sink
75
+ * @returns {(line: string) => void}
76
+ */
77
+ export function createCompilerWarningEmitter(sink = (line) => console.warn(line)) {
78
+ const emitted = new Set();
79
+ return (line) => {
80
+ const text = String(line || '').trim();
81
+ if (!text || emitted.has(text)) {
82
+ return;
83
+ }
84
+ emitted.add(text);
85
+ sink(text);
86
+ };
87
+ }
88
+
71
89
  /**
72
90
  * Run the compiler process and parse its JSON stdout.
73
91
  *
@@ -77,9 +95,12 @@ const BUNDLER_BIN = resolveBinary([
77
95
  *
78
96
  * @param {string} filePath — path for diagnostics (and file reading when no stdinSource)
79
97
  * @param {string} [stdinSource] — if provided, piped to compiler via stdin
98
+ * @param {object} compilerRunOptions
99
+ * @param {(warning: string) => void} [compilerRunOptions.onWarning]
100
+ * @param {boolean} [compilerRunOptions.suppressWarnings]
80
101
  * @returns {object}
81
102
  */
82
- function runCompiler(filePath, stdinSource, compilerOpts = {}) {
103
+ function runCompiler(filePath, stdinSource, compilerOpts = {}, compilerRunOptions = {}) {
83
104
  const args = stdinSource !== undefined
84
105
  ? ['--stdin', filePath]
85
106
  : [filePath];
@@ -102,6 +123,20 @@ function runCompiler(filePath, stdinSource, compilerOpts = {}) {
102
123
  );
103
124
  }
104
125
 
126
+ if (result.stderr && result.stderr.trim().length > 0 && compilerRunOptions.suppressWarnings !== true) {
127
+ const lines = String(result.stderr)
128
+ .split('\n')
129
+ .map((line) => line.trim())
130
+ .filter((line) => line.length > 0);
131
+ for (const line of lines) {
132
+ if (typeof compilerRunOptions.onWarning === 'function') {
133
+ compilerRunOptions.onWarning(line);
134
+ } else {
135
+ console.warn(line);
136
+ }
137
+ }
138
+ }
139
+
105
140
  try {
106
141
  return JSON.parse(result.stdout);
107
142
  } catch (err) {
@@ -143,7 +178,7 @@ function buildComponentExpressionRewrite(compPath, componentSource, compIr, comp
143
178
 
144
179
  let templateIr;
145
180
  try {
146
- templateIr = runCompiler(compPath, templateOnly, compilerOpts);
181
+ templateIr = runCompiler(compPath, templateOnly, compilerOpts, { suppressWarnings: true });
147
182
  } catch {
148
183
  return out;
149
184
  }
@@ -868,7 +903,7 @@ function transpileTypeScriptToJs(source, sourceFile) {
868
903
  fileName: sourceFile,
869
904
  compilerOptions: {
870
905
  module: ts.ModuleKind.ESNext,
871
- target: ts.ScriptTarget.ES2022,
906
+ target: ts.ScriptTarget.ES5,
872
907
  importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Preserve,
873
908
  verbatimModuleSyntax: true,
874
909
  newLine: ts.NewLineKind.LineFeed,
@@ -1103,7 +1138,7 @@ function injectPropsPrelude(source, attrs) {
1103
1138
  }
1104
1139
 
1105
1140
  const propsLiteral = renderPropsLiteralFromAttrs(attrs);
1106
- return `const props = ${propsLiteral};\n${source}`;
1141
+ return `var props = ${propsLiteral};\n${source}`;
1107
1142
  }
1108
1143
 
1109
1144
  /**
@@ -1360,6 +1395,7 @@ export async function build(options) {
1360
1395
  const componentRefFallbackCache = new Map();
1361
1396
  /** @type {Map<string, { map: Map<string, string>, ambiguous: Set<string> }>} */
1362
1397
  const componentExpressionRewriteCache = new Map();
1398
+ const emitCompilerWarning = createCompilerWarningEmitter((line) => console.warn(line));
1363
1399
 
1364
1400
  const envelopes = [];
1365
1401
  for (const entry of manifest) {
@@ -1375,7 +1411,12 @@ export async function build(options) {
1375
1411
  const compileSource = extractedServer.source;
1376
1412
 
1377
1413
  // 2b. Compile expanded page source via --stdin
1378
- const pageIr = runCompiler(sourceFile, compileSource, compilerOpts);
1414
+ const pageIr = runCompiler(
1415
+ sourceFile,
1416
+ compileSource,
1417
+ compilerOpts,
1418
+ { onWarning: emitCompilerWarning }
1419
+ );
1379
1420
  if (extractedServer.serverScript) {
1380
1421
  pageIr.server_script = extractedServer.serverScript;
1381
1422
  pageIr.prerender = extractedServer.serverScript.prerender === true;
@@ -1409,7 +1450,12 @@ export async function build(options) {
1409
1450
  compIr = componentIrCache.get(compPath);
1410
1451
  } else {
1411
1452
  const componentCompileSource = stripStyleBlocks(componentSource);
1412
- compIr = runCompiler(compPath, componentCompileSource, compilerOpts);
1453
+ compIr = runCompiler(
1454
+ compPath,
1455
+ componentCompileSource,
1456
+ compilerOpts,
1457
+ { onWarning: emitCompilerWarning }
1458
+ );
1413
1459
  componentIrCache.set(compPath, compIr);
1414
1460
  }
1415
1461
 
@@ -17,7 +17,7 @@ import { readFile } from 'node:fs/promises';
17
17
  import { join, extname } from 'node:path';
18
18
  import { build } from './build.js';
19
19
  import {
20
- executeServerScript,
20
+ executeServerRoute,
21
21
  injectSsrPayload,
22
22
  loadRouteManifest,
23
23
  resolveWithinDist,
@@ -93,9 +93,42 @@ export async function createDevServer(options) {
93
93
  return;
94
94
  }
95
95
 
96
+ if (pathname === '/__zenith/route-check') {
97
+ try {
98
+ const targetPath = String(url.searchParams.get('path') || '/');
99
+ const targetUrl = new URL(targetPath, `http://localhost:${port}`);
100
+ const routes = await loadRouteManifest(outDir);
101
+ const resolvedCheck = resolveRequestRoute(targetUrl, routes);
102
+ if (!resolvedCheck.matched || !resolvedCheck.route) {
103
+ res.writeHead(404, { 'Content-Type': 'application/json' });
104
+ res.end(JSON.stringify({ error: 'route_not_found' }));
105
+ return;
106
+ }
107
+
108
+ const checkResult = await executeServerRoute({
109
+ source: resolvedCheck.route.server_script || '',
110
+ sourcePath: resolvedCheck.route.server_script_path || '',
111
+ params: resolvedCheck.params,
112
+ requestUrl: targetUrl.toString(),
113
+ requestMethod: req.method || 'GET',
114
+ requestHeaders: req.headers,
115
+ routePattern: resolvedCheck.route.path,
116
+ routeFile: resolvedCheck.route.server_script_path || '',
117
+ routeId: resolvedCheck.route.route_id || ''
118
+ });
119
+ res.writeHead(200, { 'Content-Type': 'application/json' });
120
+ res.end(JSON.stringify(checkResult));
121
+ return;
122
+ } catch {
123
+ res.writeHead(500, { 'Content-Type': 'application/json' });
124
+ res.end(JSON.stringify({ error: 'route_check_failed' }));
125
+ return;
126
+ }
127
+ }
128
+
96
129
  try {
97
130
  const requestExt = extname(pathname);
98
- if (requestExt) {
131
+ if (requestExt && requestExt !== '.html') {
99
132
  const assetPath = join(outDir, pathname);
100
133
  const asset = await readFile(assetPath);
101
134
  const mime = MIME_TYPES[requestExt] || 'application/octet-stream';
@@ -122,11 +155,11 @@ export async function createDevServer(options) {
122
155
  throw new Error('not found');
123
156
  }
124
157
 
125
- let content = await readFile(filePath, 'utf8');
158
+ let ssrPayload = null;
126
159
  if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
127
- let payload = null;
160
+ let routeExecution = null;
128
161
  try {
129
- payload = await executeServerScript({
162
+ routeExecution = await executeServerRoute({
130
163
  source: resolved.route.server_script,
131
164
  sourcePath: resolved.route.server_script_path || '',
132
165
  params: resolved.params,
@@ -134,22 +167,53 @@ export async function createDevServer(options) {
134
167
  requestMethod: req.method || 'GET',
135
168
  requestHeaders: req.headers,
136
169
  routePattern: resolved.route.path,
137
- routeFile: resolved.route.server_script_path || ''
170
+ routeFile: resolved.route.server_script_path || '',
171
+ routeId: resolved.route.route_id || ''
138
172
  });
139
173
  } catch (error) {
140
- payload = {
141
- __zenith_error: {
174
+ routeExecution = {
175
+ result: {
176
+ kind: 'deny',
142
177
  status: 500,
143
- code: 'LOAD_FAILED',
144
178
  message: error instanceof Error ? error.message : String(error)
179
+ },
180
+ trace: {
181
+ guard: 'none',
182
+ load: 'deny'
145
183
  }
146
184
  };
147
185
  }
148
- if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
149
- content = injectSsrPayload(content, payload);
186
+
187
+ const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
188
+ const routeId = resolved.route.route_id || '';
189
+ console.log(`[Zenith] guard(${routeId || resolved.route.path}) -> ${trace.guard}`);
190
+ console.log(`[Zenith] load(${routeId || resolved.route.path}) -> ${trace.load}`);
191
+
192
+ const result = routeExecution?.result;
193
+ if (result && result.kind === 'redirect') {
194
+ const status = Number.isInteger(result.status) ? result.status : 302;
195
+ res.writeHead(status, {
196
+ Location: result.location,
197
+ 'Cache-Control': 'no-store'
198
+ });
199
+ res.end('');
200
+ return;
201
+ }
202
+ if (result && result.kind === 'deny') {
203
+ const status = Number.isInteger(result.status) ? result.status : 403;
204
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
205
+ res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
206
+ return;
207
+ }
208
+ if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
209
+ ssrPayload = result.data;
150
210
  }
151
211
  }
152
212
 
213
+ let content = await readFile(filePath, 'utf8');
214
+ if (ssrPayload) {
215
+ content = injectSsrPayload(content, ssrPayload);
216
+ }
153
217
  content = content.replace('</body>', `${HMR_CLIENT_SCRIPT}</body>`);
154
218
  res.writeHead(200, { 'Content-Type': 'text/html' });
155
219
  res.end(content);
package/dist/preview.js CHANGED
@@ -158,6 +158,55 @@ const safeRequestHeaders =
158
158
  requestHeaders && typeof requestHeaders === 'object'
159
159
  ? { ...requestHeaders }
160
160
  : {};
161
+ function parseCookies(rawCookieHeader) {
162
+ const out = Object.create(null);
163
+ const raw = String(rawCookieHeader || '');
164
+ if (!raw) return out;
165
+ const pairs = raw.split(';');
166
+ for (let i = 0; i < pairs.length; i++) {
167
+ const part = pairs[i];
168
+ const eq = part.indexOf('=');
169
+ if (eq <= 0) continue;
170
+ const key = part.slice(0, eq).trim();
171
+ if (!key) continue;
172
+ const value = part.slice(eq + 1).trim();
173
+ try {
174
+ out[key] = decodeURIComponent(value);
175
+ } catch {
176
+ out[key] = value;
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+ const cookieHeader = typeof safeRequestHeaders.cookie === 'string'
182
+ ? safeRequestHeaders.cookie
183
+ : '';
184
+ const requestCookies = parseCookies(cookieHeader);
185
+
186
+ function ctxAllow() {
187
+ return { kind: 'allow' };
188
+ }
189
+ function ctxRedirect(location, status = 302) {
190
+ return {
191
+ kind: 'redirect',
192
+ location: String(location || ''),
193
+ status: Number.isInteger(status) ? status : 302
194
+ };
195
+ }
196
+ function ctxDeny(status = 403, message = undefined) {
197
+ return {
198
+ kind: 'deny',
199
+ status: Number.isInteger(status) ? status : 403,
200
+ message: typeof message === 'string' ? message : undefined
201
+ };
202
+ }
203
+ function ctxData(payload) {
204
+ return {
205
+ kind: 'data',
206
+ data: payload
207
+ };
208
+ }
209
+
161
210
  const requestSnapshot = new Request(requestUrl, {
162
211
  method: requestMethod,
163
212
  headers: new Headers(safeRequestHeaders)
@@ -168,15 +217,32 @@ const routeMeta = {
168
217
  pattern: routePattern,
169
218
  file: routeFile ? path.relative(process.cwd(), routeFile) : ''
170
219
  };
220
+ const routeContext = {
221
+ params: routeParams,
222
+ url: new URL(requestUrl),
223
+ headers: { ...safeRequestHeaders },
224
+ cookies: requestCookies,
225
+ request: requestSnapshot,
226
+ method: requestMethod,
227
+ route: routeMeta,
228
+ env: {},
229
+ auth: {
230
+ async getSession(_ctx) {
231
+ return null;
232
+ },
233
+ async requireSession(_ctx) {
234
+ throw ctxRedirect('/login', 302);
235
+ }
236
+ },
237
+ allow: ctxAllow,
238
+ redirect: ctxRedirect,
239
+ deny: ctxDeny,
240
+ data: ctxData
241
+ };
171
242
 
172
243
  const context = vm.createContext({
173
244
  params: routeParams,
174
- ctx: {
175
- params: routeParams,
176
- url: new URL(requestUrl),
177
- request: requestSnapshot,
178
- route: routeMeta
179
- },
245
+ ctx: routeContext,
180
246
  fetch: globalThis.fetch,
181
247
  Headers: globalThis.Headers,
182
248
  Request: globalThis.Request,
@@ -251,11 +317,11 @@ async function linkModule(specifier, parentIdentifier) {
251
317
  return loadFileModule(resolvedUrl);
252
318
  }
253
319
 
254
- const allowed = new Set(['data', 'load', 'ssr_data', 'props', 'ssr', 'prerender']);
320
+ const allowed = new Set(['data', 'load', 'guard', 'ssr_data', 'props', 'ssr', 'prerender']);
255
321
  const prelude = "const params = globalThis.params;\n" +
256
322
  "const ctx = globalThis.ctx;\n" +
257
- "import { resolveServerPayload } from 'zenith:server-contract';\n" +
258
- "globalThis.resolveServerPayload = resolveServerPayload;\n";
323
+ "import { resolveRouteResult } from 'zenith:server-contract';\n" +
324
+ "globalThis.resolveRouteResult = resolveRouteResult;\n";
259
325
  const entryIdentifier = sourcePath
260
326
  ? pathToFileURL(sourcePath).href
261
327
  : 'zenith:server-script';
@@ -294,13 +360,13 @@ for (const key of namespaceKeys) {
294
360
 
295
361
  const exported = entryModule.namespace;
296
362
  try {
297
- const payload = await context.resolveServerPayload({
363
+ const resolved = await context.resolveRouteResult({
298
364
  exports: exported,
299
365
  ctx: context.ctx,
300
366
  filePath: sourcePath || 'server_script'
301
367
  });
302
368
 
303
- process.stdout.write(JSON.stringify(payload === undefined ? null : payload));
369
+ process.stdout.write(JSON.stringify(resolved || null));
304
370
  } catch (error) {
305
371
  const message = error instanceof Error ? error.message : String(error);
306
372
  process.stdout.write(
@@ -328,7 +394,34 @@ export async function createPreviewServer(options) {
328
394
  const url = new URL(req.url, `http://localhost:${port}`);
329
395
 
330
396
  try {
331
- if (extname(url.pathname)) {
397
+ if (url.pathname === '/__zenith/route-check') {
398
+ const targetPath = String(url.searchParams.get('path') || '/');
399
+ const targetUrl = new URL(targetPath, `http://localhost:${port}`);
400
+ const routes = await loadRouteManifest(distDir);
401
+ const resolvedCheck = resolveRequestRoute(targetUrl, routes);
402
+ if (!resolvedCheck.matched || !resolvedCheck.route) {
403
+ res.writeHead(404, { 'Content-Type': 'application/json' });
404
+ res.end(JSON.stringify({ error: 'route_not_found' }));
405
+ return;
406
+ }
407
+
408
+ const checkResult = await executeServerRoute({
409
+ source: resolvedCheck.route.server_script || '',
410
+ sourcePath: resolvedCheck.route.server_script_path || '',
411
+ params: resolvedCheck.params,
412
+ requestUrl: targetUrl.toString(),
413
+ requestMethod: req.method || 'GET',
414
+ requestHeaders: req.headers,
415
+ routePattern: resolvedCheck.route.path,
416
+ routeFile: resolvedCheck.route.server_script_path || '',
417
+ routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || '')
418
+ });
419
+ res.writeHead(200, { 'Content-Type': 'application/json' });
420
+ res.end(JSON.stringify(checkResult));
421
+ return;
422
+ }
423
+
424
+ if (extname(url.pathname) && extname(url.pathname) !== '.html') {
332
425
  const staticPath = resolveWithinDist(distDir, url.pathname);
333
426
  if (!staticPath || !(await fileExists(staticPath))) {
334
427
  throw new Error('not found');
@@ -358,11 +451,11 @@ export async function createPreviewServer(options) {
358
451
  throw new Error('not found');
359
452
  }
360
453
 
361
- let html = await readFile(htmlPath, 'utf8');
454
+ let ssrPayload = null;
362
455
  if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
363
- let payload = null;
456
+ let routeExecution = null;
364
457
  try {
365
- payload = await executeServerScript({
458
+ routeExecution = await executeServerRoute({
366
459
  source: resolved.route.server_script,
367
460
  sourcePath: resolved.route.server_script_path || '',
368
461
  params: resolved.params,
@@ -371,22 +464,53 @@ export async function createPreviewServer(options) {
371
464
  requestHeaders: req.headers,
372
465
  routePattern: resolved.route.path,
373
466
  routeFile: resolved.route.server_script_path || '',
374
- routeId: routeIdFromSourcePath(resolved.route.server_script_path || '')
467
+ routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
375
468
  });
376
469
  } catch (error) {
377
- payload = {
378
- __zenith_error: {
470
+ routeExecution = {
471
+ result: {
472
+ kind: 'deny',
379
473
  status: 500,
380
- code: 'LOAD_FAILED',
381
474
  message: error instanceof Error ? error.message : String(error)
475
+ },
476
+ trace: {
477
+ guard: 'none',
478
+ load: 'deny'
382
479
  }
383
480
  };
384
481
  }
385
- if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
386
- html = injectSsrPayload(html, payload);
482
+
483
+ const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
484
+ const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
485
+ console.log(`[Zenith] guard(${routeId}) -> ${trace.guard}`);
486
+ console.log(`[Zenith] load(${routeId}) -> ${trace.load}`);
487
+
488
+ const result = routeExecution?.result;
489
+ if (result && result.kind === 'redirect') {
490
+ const status = Number.isInteger(result.status) ? result.status : 302;
491
+ res.writeHead(status, {
492
+ Location: result.location,
493
+ 'Cache-Control': 'no-store'
494
+ });
495
+ res.end('');
496
+ return;
497
+ }
498
+ if (result && result.kind === 'deny') {
499
+ const status = Number.isInteger(result.status) ? result.status : 403;
500
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
501
+ res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
502
+ return;
503
+ }
504
+ if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
505
+ ssrPayload = result.data;
387
506
  }
388
507
  }
389
508
 
509
+ let html = await readFile(htmlPath, 'utf8');
510
+ if (ssrPayload) {
511
+ html = injectSsrPayload(html, ssrPayload);
512
+ }
513
+
390
514
  res.writeHead(200, { 'Content-Type': 'text/html' });
391
515
  res.end(html);
392
516
  } catch {
@@ -416,6 +540,13 @@ export async function createPreviewServer(options) {
416
540
  * server_script?: string | null;
417
541
  * server_script_path?: string | null;
418
542
  * prerender?: boolean;
543
+ * route_id?: string;
544
+ * pattern?: string;
545
+ * params_shape?: Record<string, string>;
546
+ * has_guard?: boolean;
547
+ * has_load?: boolean;
548
+ * guard_module_ref?: string | null;
549
+ * load_module_ref?: string | null;
419
550
  * }} PreviewRoute
420
551
  */
421
552
 
@@ -446,9 +577,16 @@ export const matchRoute = matchManifestRoute;
446
577
 
447
578
  /**
448
579
  * @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>}
580
+ * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, load: string } }>}
450
581
  */
451
- export async function executeServerScript(input) {
582
+ export async function executeServerRoute(input) {
583
+ if (!input.source || !String(input.source).trim()) {
584
+ return {
585
+ result: { kind: 'data', data: {} },
586
+ trace: { guard: 'none', load: 'none' }
587
+ };
588
+ }
589
+
452
590
  const payload = await spawnNodeServerRunner({
453
591
  source: input.source,
454
592
  sourcePath: input.sourcePath,
@@ -462,12 +600,85 @@ export async function executeServerScript(input) {
462
600
  });
463
601
 
464
602
  if (payload === null || payload === undefined) {
465
- return null;
603
+ return {
604
+ result: { kind: 'data', data: {} },
605
+ trace: { guard: 'none', load: 'none' }
606
+ };
466
607
  }
467
608
  if (typeof payload !== 'object' || Array.isArray(payload)) {
468
609
  throw new Error('[zenith-preview] server script payload must be an object');
469
610
  }
470
- return payload;
611
+
612
+ const errorEnvelope = payload.__zenith_error;
613
+ if (errorEnvelope && typeof errorEnvelope === 'object') {
614
+ return {
615
+ result: {
616
+ kind: 'deny',
617
+ status: 500,
618
+ message: String(errorEnvelope.message || 'Server route execution failed')
619
+ },
620
+ trace: { guard: 'none', load: 'deny' }
621
+ };
622
+ }
623
+
624
+ const result = payload.result;
625
+ const trace = payload.trace;
626
+ if (result && typeof result === 'object' && !Array.isArray(result) && typeof result.kind === 'string') {
627
+ return {
628
+ result,
629
+ trace: trace && typeof trace === 'object'
630
+ ? {
631
+ guard: String(trace.guard || 'none'),
632
+ load: String(trace.load || 'none')
633
+ }
634
+ : { guard: 'none', load: 'none' }
635
+ };
636
+ }
637
+
638
+ return {
639
+ result: {
640
+ kind: 'data',
641
+ data: payload
642
+ },
643
+ trace: { guard: 'none', load: 'data' }
644
+ };
645
+ }
646
+
647
+ /**
648
+ * @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
649
+ * @returns {Promise<Record<string, unknown> | null>}
650
+ */
651
+ export async function executeServerScript(input) {
652
+ const execution = await executeServerRoute(input);
653
+ const result = execution?.result;
654
+ if (!result || typeof result !== 'object') {
655
+ return null;
656
+ }
657
+ if (result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
658
+ return result.data;
659
+ }
660
+
661
+ if (result.kind === 'redirect') {
662
+ return {
663
+ __zenith_error: {
664
+ status: Number.isInteger(result.status) ? result.status : 302,
665
+ code: 'REDIRECT',
666
+ message: `Redirect to ${String(result.location || '')}`
667
+ }
668
+ };
669
+ }
670
+
671
+ if (result.kind === 'deny') {
672
+ return {
673
+ __zenith_error: {
674
+ status: Number.isInteger(result.status) ? result.status : 403,
675
+ code: 'ACCESS_DENIED',
676
+ message: String(result.message || 'Access denied')
677
+ }
678
+ };
679
+ }
680
+
681
+ return {};
471
682
  }
472
683
 
473
684
  /**
@@ -609,7 +820,7 @@ export function resolveWithinDist(distDir, requestPath) {
609
820
  */
610
821
  function sanitizeRequestHeaders(headers) {
611
822
  const out = Object.create(null);
612
- const denyExact = new Set(['authorization', 'cookie', 'proxy-authorization', 'set-cookie']);
823
+ const denyExact = new Set(['proxy-authorization', 'set-cookie']);
613
824
  const denyPrefixes = ['x-forwarded-', 'cf-'];
614
825
  for (const [rawKey, rawValue] of Object.entries(headers || {})) {
615
826
  const key = String(rawKey || '').toLowerCase();
@@ -2,9 +2,73 @@
2
2
  // ---------------------------------------------------------------------------
3
3
  // Shared validation and payload resolution logic for <script server> blocks.
4
4
 
5
- const NEW_KEYS = new Set(['data', 'load', 'prerender']);
5
+ const NEW_KEYS = new Set(['data', 'load', 'guard', 'prerender']);
6
6
  const LEGACY_KEYS = new Set(['ssr_data', 'props', 'ssr', 'prerender']);
7
- const ALLOWED_KEYS = new Set(['data', 'load', 'prerender', 'ssr_data', 'props', 'ssr']);
7
+ const ALLOWED_KEYS = new Set(['data', 'load', 'guard', 'prerender', 'ssr_data', 'props', 'ssr']);
8
+
9
+ const ROUTE_RESULT_KINDS = new Set(['allow', 'redirect', 'deny', 'data']);
10
+
11
+ export function allow() {
12
+ return { kind: 'allow' };
13
+ }
14
+
15
+ export function redirect(location, status = 302) {
16
+ return {
17
+ kind: 'redirect',
18
+ location: String(location || ''),
19
+ status: Number.isInteger(status) ? status : 302
20
+ };
21
+ }
22
+
23
+ export function deny(status = 403, message = undefined) {
24
+ return {
25
+ kind: 'deny',
26
+ status: Number.isInteger(status) ? status : 403,
27
+ message: typeof message === 'string' ? message : undefined
28
+ };
29
+ }
30
+
31
+ export function data(payload) {
32
+ return { kind: 'data', data: payload };
33
+ }
34
+
35
+ function isRouteResultLike(value) {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
37
+ return false;
38
+ }
39
+ const kind = value.kind;
40
+ return typeof kind === 'string' && ROUTE_RESULT_KINDS.has(kind);
41
+ }
42
+
43
+ function assertValidRouteResultShape(value, where, allowedKinds) {
44
+ if (!isRouteResultLike(value)) {
45
+ throw new Error(`[Zenith] ${where}: invalid route result. Expected object with kind.`);
46
+ }
47
+ const kind = value.kind;
48
+ if (!allowedKinds.has(kind)) {
49
+ throw new Error(
50
+ `[Zenith] ${where}: kind "${kind}" is not allowed here (allowed: ${Array.from(allowedKinds).join(', ')}).`
51
+ );
52
+ }
53
+
54
+ if (kind === 'redirect') {
55
+ if (typeof value.location !== 'string' || value.location.length === 0) {
56
+ throw new Error(`[Zenith] ${where}: redirect requires non-empty string location.`);
57
+ }
58
+ if (value.status !== undefined && (!Number.isInteger(value.status) || value.status < 300 || value.status > 399)) {
59
+ throw new Error(`[Zenith] ${where}: redirect status must be an integer 3xx.`);
60
+ }
61
+ }
62
+
63
+ if (kind === 'deny') {
64
+ if (!Number.isInteger(value.status) || (value.status !== 401 && value.status !== 403)) {
65
+ throw new Error(`[Zenith] ${where}: deny status must be 401 or 403.`);
66
+ }
67
+ if (value.message !== undefined && typeof value.message !== 'string') {
68
+ throw new Error(`[Zenith] ${where}: deny message must be a string when provided.`);
69
+ }
70
+ }
71
+ }
8
72
 
9
73
  export function validateServerExports({ exports, filePath }) {
10
74
  const exportKeys = Object.keys(exports);
@@ -16,6 +80,7 @@ export function validateServerExports({ exports, filePath }) {
16
80
 
17
81
  const hasData = 'data' in exports;
18
82
  const hasLoad = 'load' in exports;
83
+ const hasGuard = 'guard' in exports;
19
84
 
20
85
  const hasNew = hasData || hasLoad;
21
86
  const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
@@ -47,6 +112,20 @@ export function validateServerExports({ exports, filePath }) {
47
112
  throw new Error(`[Zenith] ${filePath}: "load(ctx)" must not contain rest parameters.`);
48
113
  }
49
114
  }
115
+
116
+ if (hasGuard && typeof exports.guard !== 'function') {
117
+ throw new Error(`[Zenith] ${filePath}: "guard" must be a function.`);
118
+ }
119
+ if (hasGuard) {
120
+ if (exports.guard.length !== 1) {
121
+ throw new Error(`[Zenith] ${filePath}: "guard(ctx)" must take exactly 1 argument.`);
122
+ }
123
+ const fnStr = exports.guard.toString();
124
+ const paramsMatch = fnStr.match(/^[^{=]+\(([^)]*)\)/);
125
+ if (paramsMatch && paramsMatch[1].includes('...')) {
126
+ throw new Error(`[Zenith] ${filePath}: "guard(ctx)" must not contain rest parameters.`);
127
+ }
128
+ }
50
129
  }
51
130
 
52
131
  export function assertJsonSerializable(value, where = 'payload') {
@@ -110,37 +189,90 @@ export function assertJsonSerializable(value, where = 'payload') {
110
189
  walk(value, '$');
111
190
  }
112
191
 
113
- export async function resolveServerPayload({ exports, ctx, filePath }) {
192
+ export async function resolveRouteResult({ exports, ctx, filePath }) {
114
193
  validateServerExports({ exports, filePath });
115
194
 
195
+ const trace = {
196
+ guard: 'none',
197
+ load: 'none'
198
+ };
199
+
200
+ if ('guard' in exports) {
201
+ const guardRaw = await exports.guard(ctx);
202
+ const guardResult = guardRaw == null ? allow() : guardRaw;
203
+ assertValidRouteResultShape(
204
+ guardResult,
205
+ `${filePath}: guard(ctx) return`,
206
+ new Set(['allow', 'redirect', 'deny'])
207
+ );
208
+ trace.guard = guardResult.kind;
209
+ if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
210
+ return { result: guardResult, trace };
211
+ }
212
+ }
213
+
116
214
  let payload;
117
215
  if ('load' in exports) {
118
- payload = await exports.load(ctx);
119
- assertJsonSerializable(payload, `${filePath}: load(ctx) return`);
120
- return payload;
216
+ const loadRaw = await exports.load(ctx);
217
+ let loadResult = null;
218
+ if (isRouteResultLike(loadRaw)) {
219
+ loadResult = loadRaw;
220
+ assertValidRouteResultShape(
221
+ loadResult,
222
+ `${filePath}: load(ctx) return`,
223
+ new Set(['data', 'redirect', 'deny'])
224
+ );
225
+ } else {
226
+ assertJsonSerializable(loadRaw, `${filePath}: load(ctx) return`);
227
+ loadResult = data(loadRaw);
228
+ }
229
+ trace.load = loadResult.kind;
230
+ return { result: loadResult, trace };
121
231
  }
122
232
  if ('data' in exports) {
123
233
  payload = exports.data;
124
234
  assertJsonSerializable(payload, `${filePath}: data export`);
125
- return payload;
235
+ trace.load = 'data';
236
+ return { result: data(payload), trace };
126
237
  }
127
238
 
128
239
  // legacy fallback
129
240
  if ('ssr_data' in exports) {
130
241
  payload = exports.ssr_data;
131
242
  assertJsonSerializable(payload, `${filePath}: ssr_data export`);
132
- return payload;
243
+ trace.load = 'data';
244
+ return { result: data(payload), trace };
133
245
  }
134
246
  if ('props' in exports) {
135
247
  payload = exports.props;
136
248
  assertJsonSerializable(payload, `${filePath}: props export`);
137
- return payload;
249
+ trace.load = 'data';
250
+ return { result: data(payload), trace };
138
251
  }
139
252
  if ('ssr' in exports) {
140
253
  payload = exports.ssr;
141
254
  assertJsonSerializable(payload, `${filePath}: ssr export`);
142
- return payload;
255
+ trace.load = 'data';
256
+ return { result: data(payload), trace };
257
+ }
258
+
259
+ return { result: data({}), trace };
260
+ }
261
+
262
+ export async function resolveServerPayload({ exports, ctx, filePath }) {
263
+ const resolved = await resolveRouteResult({ exports, ctx, filePath });
264
+ if (!resolved || !resolved.result || typeof resolved.result !== 'object') {
265
+ return {};
266
+ }
267
+
268
+ if (resolved.result.kind === 'data') {
269
+ return resolved.result.data;
270
+ }
271
+ if (resolved.result.kind === 'allow') {
272
+ return {};
143
273
  }
144
274
 
145
- return {};
275
+ throw new Error(
276
+ `[Zenith] ${filePath}: resolveServerPayload() expected data but received ${resolved.result.kind}. Use resolveRouteResult() for guard/load flows.`
277
+ );
146
278
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/cli",
3
- "version": "0.5.0-beta.2.12",
3
+ "version": "0.5.0-beta.2.16",
4
4
  "description": "Deterministic project orchestrator for Zenith framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "prepublishOnly": "npm run build"
25
25
  },
26
26
  "dependencies": {
27
- "@zenithbuild/compiler": "0.5.0-beta.2.12"
27
+ "@zenithbuild/compiler": "0.5.0-beta.2.16"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@jest/globals": "^30.2.0",