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

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/build.js CHANGED
@@ -15,7 +15,7 @@ import { spawn, spawnSync } from 'node:child_process';
15
15
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { mkdir, readdir, rm, stat } from 'node:fs/promises';
17
17
  import { createRequire } from 'node:module';
18
- import { basename, dirname, join, relative, resolve } from 'node:path';
18
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { generateManifest } from './manifest.js';
21
21
  import { buildComponentRegistry, expandComponents, extractTemplate, isDocumentMode } from './resolve-components.js';
@@ -487,7 +487,7 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
487
487
  const scriptRe = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
488
488
  const serverMatches = [];
489
489
  const reservedServerExportRe =
490
- /\bexport\s+const\s+(?:data|prerender)\b|\bexport\s+(?:async\s+)?function\s+load\s*\(|\bexport\s+const\s+load\s*=/;
490
+ /\bexport\s+const\s+(?:data|prerender|guard|load)\b|\bexport\s+(?:async\s+)?function\s+(?:load|guard)\s*\(|\bexport\s+const\s+(?:load|guard)\s*=/;
491
491
 
492
492
  for (const match of source.matchAll(scriptRe)) {
493
493
  const attrs = String(match[1] || '');
@@ -498,8 +498,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
498
498
  throw new Error(
499
499
  `Zenith server script contract violation:\n` +
500
500
  ` File: ${sourceFile}\n` +
501
- ` Reason: data/load/prerender exports are only allowed in <script server lang="ts">\n` +
502
- ` Example: move export const data or export const load into <script server lang="ts">`
501
+ ` Reason: guard/load/data exports are only allowed in <script server lang="ts"> or adjacent .guard.ts / .load.ts files\n` +
502
+ ` Example: move the export into <script server lang="ts">`
503
503
  );
504
504
  }
505
505
 
@@ -570,6 +570,25 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
570
570
  );
571
571
  }
572
572
 
573
+ const guardFnMatch = serverSource.match(/\bexport\s+(?:async\s+)?function\s+guard\s*\(([^)]*)\)/);
574
+ const guardConstParenMatch = serverSource.match(/\bexport\s+const\s+guard\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>/);
575
+ const guardConstSingleArgMatch = serverSource.match(
576
+ /\bexport\s+const\s+guard\s*=\s*(?:async\s*)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/
577
+ );
578
+ const hasGuard = Boolean(guardFnMatch || guardConstParenMatch || guardConstSingleArgMatch);
579
+ const guardMatchCount =
580
+ Number(Boolean(guardFnMatch)) +
581
+ Number(Boolean(guardConstParenMatch)) +
582
+ Number(Boolean(guardConstSingleArgMatch));
583
+ if (guardMatchCount > 1) {
584
+ throw new Error(
585
+ `Zenith server script contract violation:\n` +
586
+ ` File: ${sourceFile}\n` +
587
+ ` Reason: multiple guard exports detected\n` +
588
+ ` Example: keep exactly one export const guard = async (ctx) => ({ ... })`
589
+ );
590
+ }
591
+
573
592
  const hasData = /\bexport\s+const\s+data\b/.test(serverSource);
574
593
  const hasSsrData = /\bexport\s+const\s+ssr_data\b/.test(serverSource);
575
594
  const hasSsr = /\bexport\s+const\s+ssr\b/.test(serverSource);
@@ -610,6 +629,24 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
610
629
  }
611
630
  }
612
631
 
632
+ if (hasGuard) {
633
+ const singleArg = String(guardConstSingleArgMatch?.[1] || '').trim();
634
+ const paramsText = String((guardFnMatch || guardConstParenMatch)?.[1] || '').trim();
635
+ const arity = singleArg
636
+ ? 1
637
+ : paramsText.length === 0
638
+ ? 0
639
+ : paramsText.split(',').length;
640
+ if (arity !== 1) {
641
+ throw new Error(
642
+ `Zenith server script contract violation:\n` +
643
+ ` File: ${sourceFile}\n` +
644
+ ` Reason: guard(ctx) must accept exactly one argument\n` +
645
+ ` Example: export const guard = async (ctx) => ({ ... })`
646
+ );
647
+ }
648
+ }
649
+
613
650
  const prerenderMatch = serverSource.match(/\bexport\s+const\s+prerender\s*=\s*([^\n;]+)/);
614
651
  let prerender = false;
615
652
  if (prerenderMatch) {
@@ -631,6 +668,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
631
668
  serverScript: {
632
669
  source: serverSource,
633
670
  prerender,
671
+ has_guard: hasGuard,
672
+ has_load: hasLoad,
634
673
  source_path: sourceFile
635
674
  }
636
675
  };
@@ -644,6 +683,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
644
683
  serverScript: {
645
684
  source: serverSource,
646
685
  prerender,
686
+ has_guard: hasGuard,
687
+ has_load: hasLoad,
647
688
  source_path: sourceFile
648
689
  }
649
690
  };
@@ -1403,6 +1444,14 @@ export async function build(options) {
1403
1444
  const rawSource = readFileSync(sourceFile, 'utf8');
1404
1445
  const componentUsageAttrs = collectComponentUsageAttrs(rawSource, registry);
1405
1446
 
1447
+ const baseName = sourceFile.slice(0, -extname(sourceFile).length);
1448
+ let adjacentGuard = null;
1449
+ let adjacentLoad = null;
1450
+ for (const ext of ['.ts', '.js']) {
1451
+ if (!adjacentGuard && existsSync(`${baseName}.guard${ext}`)) adjacentGuard = `${baseName}.guard${ext}`;
1452
+ if (!adjacentLoad && existsSync(`${baseName}.load${ext}`)) adjacentLoad = `${baseName}.load${ext}`;
1453
+ }
1454
+
1406
1455
  // 2a. Expand PascalCase component tags
1407
1456
  const { expandedSource, usedComponents } = expandComponents(
1408
1457
  rawSource, registry, sourceFile
@@ -1417,6 +1466,10 @@ export async function build(options) {
1417
1466
  compilerOpts,
1418
1467
  { onWarning: emitCompilerWarning }
1419
1468
  );
1469
+
1470
+ const hasGuard = (extractedServer.serverScript && extractedServer.serverScript.has_guard) || adjacentGuard !== null;
1471
+ const hasLoad = (extractedServer.serverScript && extractedServer.serverScript.has_load) || adjacentLoad !== null;
1472
+
1420
1473
  if (extractedServer.serverScript) {
1421
1474
  pageIr.server_script = extractedServer.serverScript;
1422
1475
  pageIr.prerender = extractedServer.serverScript.prerender === true;
@@ -1425,6 +1478,20 @@ export async function build(options) {
1425
1478
  }
1426
1479
  }
1427
1480
 
1481
+ // Static Build Route Protection Policy
1482
+ if (pageIr.prerender === true && (hasGuard || hasLoad)) {
1483
+ throw new Error(
1484
+ `[zenith] Build failed for ${entry.file}: protected routes require SSR/runtime. ` +
1485
+ `Cannot prerender a static route with a \`guard\` or \`load\` function.`
1486
+ );
1487
+ }
1488
+
1489
+ // Apply metadata to IR
1490
+ pageIr.has_guard = hasGuard;
1491
+ pageIr.has_load = hasLoad;
1492
+ pageIr.guard_module_ref = adjacentGuard ? relative(srcDir, adjacentGuard).replaceAll('\\', '/') : null;
1493
+ pageIr.load_module_ref = adjacentLoad ? relative(srcDir, adjacentLoad).replaceAll('\\', '/') : null;
1494
+
1428
1495
  // Ensure IR has required array fields for merging
1429
1496
  pageIr.components_scripts = pageIr.components_scripts || {};
1430
1497
  pageIr.component_instances = pageIr.component_instances || [];
@@ -35,21 +35,8 @@ const MIME_TYPES = {
35
35
  '.svg': 'image/svg+xml'
36
36
  };
37
37
 
38
- const HMR_CLIENT_SCRIPT = `
39
- <script>
40
- // Zenith HMR Client V0
41
- (function() {
42
- const es = new EventSource('/__zenith_hmr');
43
- es.onmessage = function(event) {
44
- if (event.data === 'reload') {
45
- window.location.reload();
46
- }
47
- };
48
- es.onerror = function() {
49
- setTimeout(function() { window.location.reload(); }, 1000);
50
- };
51
- })();
52
- </script>`;
38
+ // Note: V0 HMR script injection has been moved to the runtime client.
39
+ // This server purely hosts the V1 HMR contract endpoints.
53
40
 
54
41
  /**
55
42
  * Create and start a development server.
@@ -69,21 +56,52 @@ export async function createDevServer(options) {
69
56
  const hmrClients = [];
70
57
  let _watcher = null;
71
58
 
59
+ let buildId = 0;
60
+ let buildStatus = 'ok'; // 'ok' | 'error' | 'building'
61
+ let lastBuildMs = Date.now();
62
+ let durationMs = 0;
63
+ let buildError = null;
64
+
65
+ // We can't know the exact CSS hashed filename here easily without parsing the dist manifest,
66
+ // but the runtime handles standard HMR updates via generic fetch if we pass a timestamp,
67
+ // or we can pass an empty string and rely on the client's `swapStylesheet`.
68
+ let currentCssHref = '';
69
+
70
+ function _broadcastEvent(type, payload = {}) {
71
+ const data = JSON.stringify({
72
+ buildId,
73
+ ...payload
74
+ });
75
+ for (const client of hmrClients) {
76
+ try {
77
+ client.write(`event: ${type}\ndata: ${data}\n\n`);
78
+ } catch {
79
+ // client disconnected
80
+ }
81
+ }
82
+ }
83
+
72
84
  // Initial build
73
- await build({ pagesDir, outDir, config });
85
+ try {
86
+ await build({ pagesDir, outDir, config });
87
+ } catch (err) {
88
+ buildStatus = 'error';
89
+ buildError = { message: err instanceof Error ? err.message : String(err) };
90
+ }
74
91
 
75
92
  const server = createServer(async (req, res) => {
76
93
  const url = new URL(req.url, `http://localhost:${port}`);
77
94
  let pathname = url.pathname;
78
95
 
79
- // HMR endpoint
96
+ // Legacy HMR endpoint (deprecated but kept alive to avoid breaking old caches instantly)
80
97
  if (pathname === '/__zenith_hmr') {
81
98
  res.writeHead(200, {
82
99
  'Content-Type': 'text/event-stream',
83
- 'Cache-Control': 'no-cache',
84
- 'Connection': 'keep-alive'
100
+ 'Cache-Control': 'no-store',
101
+ 'Connection': 'keep-alive',
102
+ 'X-Zenith-Deprecated': 'true'
85
103
  });
86
- // Flush headers by sending initial comment
104
+ console.warn('[zenith] Warning: /__zenith_hmr is legacy; use /__zenith_dev/events');
87
105
  res.write(': connected\n\n');
88
106
  hmrClients.push(res);
89
107
  req.on('close', () => {
@@ -93,10 +111,65 @@ export async function createDevServer(options) {
93
111
  return;
94
112
  }
95
113
 
114
+ // V1 Dev State Endpoint
115
+ if (pathname === '/__zenith_dev/state') {
116
+ res.writeHead(200, {
117
+ 'Content-Type': 'application/json',
118
+ 'Cache-Control': 'no-store'
119
+ });
120
+ res.end(JSON.stringify({
121
+ serverUrl: `http://localhost:${port}`,
122
+ buildId,
123
+ status: buildStatus,
124
+ lastBuildMs,
125
+ durationMs,
126
+ cssHref: currentCssHref,
127
+ error: buildError
128
+ }));
129
+ return;
130
+ }
131
+
132
+ // V1 Dev Events Endpoint (SSE)
133
+ if (pathname === '/__zenith_dev/events') {
134
+ res.writeHead(200, {
135
+ 'Content-Type': 'text/event-stream',
136
+ 'Cache-Control': 'no-store',
137
+ 'Connection': 'keep-alive'
138
+ });
139
+ res.write('event: connected\ndata: {}\n\n');
140
+ hmrClients.push(res);
141
+ req.on('close', () => {
142
+ const idx = hmrClients.indexOf(res);
143
+ if (idx !== -1) hmrClients.splice(idx, 1);
144
+ });
145
+ return;
146
+ }
147
+
96
148
  if (pathname === '/__zenith/route-check') {
97
149
  try {
150
+ // Security: Require explicitly designated header to prevent public oracle probing
151
+ if (req.headers['x-zenith-route-check'] !== '1') {
152
+ res.writeHead(403, { 'Content-Type': 'application/json' });
153
+ res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
154
+ return;
155
+ }
156
+
98
157
  const targetPath = String(url.searchParams.get('path') || '/');
158
+
159
+ // Security: Prevent protocol/domain injection in path
160
+ if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
161
+ res.writeHead(400, { 'Content-Type': 'application/json' });
162
+ res.end(JSON.stringify({ error: 'invalid_path_format' }));
163
+ return;
164
+ }
165
+
99
166
  const targetUrl = new URL(targetPath, `http://localhost:${port}`);
167
+ if (targetUrl.origin !== `http://localhost:${port}`) {
168
+ res.writeHead(400, { 'Content-Type': 'application/json' });
169
+ res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
170
+ return;
171
+ }
172
+
100
173
  const routes = await loadRouteManifest(outDir);
101
174
  const resolvedCheck = resolveRequestRoute(targetUrl, routes);
102
175
  if (!resolvedCheck.matched || !resolvedCheck.route) {
@@ -114,10 +187,36 @@ export async function createDevServer(options) {
114
187
  requestHeaders: req.headers,
115
188
  routePattern: resolvedCheck.route.path,
116
189
  routeFile: resolvedCheck.route.server_script_path || '',
117
- routeId: resolvedCheck.route.route_id || ''
190
+ routeId: resolvedCheck.route.route_id || '',
191
+ guardOnly: true
192
+ });
193
+ // Security: Enforce relative or same-origin redirects
194
+ if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
195
+ const loc = String(checkResult.result.location || '/');
196
+ if (loc.includes('://') || loc.startsWith('//')) {
197
+ try {
198
+ const parsedLoc = new URL(loc);
199
+ if (parsedLoc.origin !== targetUrl.origin) {
200
+ checkResult.result.location = '/'; // Fallback to root for open redirect attempt
201
+ }
202
+ } catch {
203
+ checkResult.result.location = '/';
204
+ }
205
+ }
206
+ }
207
+
208
+ res.writeHead(200, {
209
+ 'Content-Type': 'application/json',
210
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
211
+ 'Pragma': 'no-cache',
212
+ 'Expires': '0',
213
+ 'Vary': 'Cookie'
118
214
  });
119
- res.writeHead(200, { 'Content-Type': 'application/json' });
120
- res.end(JSON.stringify(checkResult));
215
+ res.end(JSON.stringify({
216
+ result: checkResult?.result || checkResult,
217
+ routeId: resolvedCheck.route.route_id || '',
218
+ to: targetUrl.toString()
219
+ }));
121
220
  return;
122
221
  } catch {
123
222
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -171,15 +270,10 @@ export async function createDevServer(options) {
171
270
  routeId: resolved.route.route_id || ''
172
271
  });
173
272
  } catch (error) {
174
- routeExecution = {
175
- result: {
176
- kind: 'deny',
177
- status: 500,
273
+ ssrPayload = {
274
+ __zenith_error: {
275
+ code: 'LOAD_FAILED',
178
276
  message: error instanceof Error ? error.message : String(error)
179
- },
180
- trace: {
181
- guard: 'none',
182
- load: 'deny'
183
277
  }
184
278
  };
185
279
  }
@@ -214,7 +308,6 @@ export async function createDevServer(options) {
214
308
  if (ssrPayload) {
215
309
  content = injectSsrPayload(content, ssrPayload);
216
310
  }
217
- content = content.replace('</body>', `${HMR_CLIENT_SCRIPT}</body>`);
218
311
  res.writeHead(200, { 'Content-Type': 'text/html' });
219
312
  res.end(content);
220
313
  } catch {
@@ -236,17 +329,62 @@ export async function createDevServer(options) {
236
329
  }
237
330
  }
238
331
 
332
+ let _buildDebounce = null;
333
+ let _queuedFiles = new Set();
334
+
239
335
  /**
240
336
  * Start watching the pages directory for changes.
241
337
  */
242
338
  function _startWatcher() {
243
339
  try {
244
- _watcher = watch(pagesDir, { recursive: true }, async (eventType, filename) => {
340
+ _watcher = watch(pagesDir, { recursive: true }, (eventType, filename) => {
245
341
  if (!filename) return;
246
342
 
247
- // Rebuild
248
- await build({ pagesDir, outDir, config });
249
- _broadcastReload();
343
+ _queuedFiles.add(filename);
344
+
345
+ if (_buildDebounce !== null) {
346
+ clearTimeout(_buildDebounce);
347
+ }
348
+
349
+ _buildDebounce = setTimeout(async () => {
350
+ _buildDebounce = null;
351
+ const changed = Array.from(_queuedFiles);
352
+ _queuedFiles.clear();
353
+
354
+ buildId++;
355
+ buildStatus = 'building';
356
+ _broadcastEvent('build_start', { changedFiles: changed });
357
+
358
+ const startTime = Date.now();
359
+ try {
360
+ await build({ pagesDir, outDir, config });
361
+ buildStatus = 'ok';
362
+ buildError = null;
363
+ lastBuildMs = Date.now();
364
+ durationMs = lastBuildMs - startTime;
365
+
366
+ _broadcastEvent('build_complete', {
367
+ durationMs,
368
+ status: buildStatus
369
+ });
370
+
371
+ const onlyCss = changed.every(f => f.endsWith('.css'));
372
+ if (onlyCss) {
373
+ // Let the client fetch the updated CSS automatically
374
+ _broadcastEvent('css_update', {});
375
+ } else {
376
+ _broadcastEvent('reload', {});
377
+ }
378
+ } catch (err) {
379
+ const fullError = err instanceof Error ? err.message : String(err);
380
+ buildStatus = 'error';
381
+ buildError = { message: fullError.length > 10000 ? fullError.slice(0, 10000) + '... (truncated)' : fullError };
382
+ lastBuildMs = Date.now();
383
+ durationMs = lastBuildMs - startTime;
384
+
385
+ _broadcastEvent('build_error', buildError);
386
+ }
387
+ }, 50);
250
388
  });
251
389
  } catch {
252
390
  // fs.watch may not support recursive on all platforms
package/dist/preview.js CHANGED
@@ -47,6 +47,7 @@ const requestHeaders = JSON.parse(process.env.ZENITH_SERVER_REQUEST_HEADERS || '
47
47
  const routePattern = process.env.ZENITH_SERVER_ROUTE_PATTERN || '';
48
48
  const routeFile = process.env.ZENITH_SERVER_ROUTE_FILE || sourcePath || '';
49
49
  const routeId = process.env.ZENITH_SERVER_ROUTE_ID || routePattern || '';
50
+ const guardOnly = process.env.ZENITH_SERVER_GUARD_ONLY === '1';
50
51
 
51
52
  if (!source.trim()) {
52
53
  process.stdout.write('null');
@@ -363,7 +364,8 @@ try {
363
364
  const resolved = await context.resolveRouteResult({
364
365
  exports: exported,
365
366
  ctx: context.ctx,
366
- filePath: sourcePath || 'server_script'
367
+ filePath: sourcePath || 'server_script',
368
+ guardOnly: guardOnly
367
369
  });
368
370
 
369
371
  process.stdout.write(JSON.stringify(resolved || null));
@@ -395,8 +397,29 @@ export async function createPreviewServer(options) {
395
397
 
396
398
  try {
397
399
  if (url.pathname === '/__zenith/route-check') {
400
+ // Security: Require explicitly designated header to prevent public oracle probing
401
+ if (req.headers['x-zenith-route-check'] !== '1') {
402
+ res.writeHead(403, { 'Content-Type': 'application/json' });
403
+ res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
404
+ return;
405
+ }
406
+
398
407
  const targetPath = String(url.searchParams.get('path') || '/');
408
+
409
+ // Security: Prevent protocol/domain injection in path
410
+ if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
411
+ res.writeHead(400, { 'Content-Type': 'application/json' });
412
+ res.end(JSON.stringify({ error: 'invalid_path_format' }));
413
+ return;
414
+ }
415
+
399
416
  const targetUrl = new URL(targetPath, `http://localhost:${port}`);
417
+ if (targetUrl.origin !== `http://localhost:${port}`) {
418
+ res.writeHead(400, { 'Content-Type': 'application/json' });
419
+ res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
420
+ return;
421
+ }
422
+
400
423
  const routes = await loadRouteManifest(distDir);
401
424
  const resolvedCheck = resolveRequestRoute(targetUrl, routes);
402
425
  if (!resolvedCheck.matched || !resolvedCheck.route) {
@@ -414,10 +437,36 @@ export async function createPreviewServer(options) {
414
437
  requestHeaders: req.headers,
415
438
  routePattern: resolvedCheck.route.path,
416
439
  routeFile: resolvedCheck.route.server_script_path || '',
417
- routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || '')
440
+ routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || ''),
441
+ guardOnly: true
442
+ });
443
+ // Security: Enforce relative or same-origin redirects
444
+ if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
445
+ const loc = String(checkResult.result.location || '/');
446
+ if (loc.includes('://') || loc.startsWith('//')) {
447
+ try {
448
+ const parsedLoc = new URL(loc);
449
+ if (parsedLoc.origin !== targetUrl.origin) {
450
+ checkResult.result.location = '/'; // Fallback to root for open redirect attempt
451
+ }
452
+ } catch {
453
+ checkResult.result.location = '/';
454
+ }
455
+ }
456
+ }
457
+
458
+ res.writeHead(200, {
459
+ 'Content-Type': 'application/json',
460
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
461
+ 'Pragma': 'no-cache',
462
+ 'Expires': '0',
463
+ 'Vary': 'Cookie'
418
464
  });
419
- res.writeHead(200, { 'Content-Type': 'application/json' });
420
- res.end(JSON.stringify(checkResult));
465
+ res.end(JSON.stringify({
466
+ result: checkResult?.result || checkResult,
467
+ routeId: resolvedCheck.route.route_id || '',
468
+ to: targetUrl.toString()
469
+ }));
421
470
  return;
422
471
  }
423
472
 
@@ -467,15 +516,10 @@ export async function createPreviewServer(options) {
467
516
  routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
468
517
  });
469
518
  } catch (error) {
470
- routeExecution = {
471
- result: {
472
- kind: 'deny',
473
- status: 500,
519
+ ssrPayload = {
520
+ __zenith_error: {
521
+ code: 'LOAD_FAILED',
474
522
  message: error instanceof Error ? error.message : String(error)
475
- },
476
- trace: {
477
- guard: 'none',
478
- load: 'deny'
479
523
  }
480
524
  };
481
525
  }
@@ -579,8 +623,19 @@ export const matchRoute = matchManifestRoute;
579
623
  * @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
580
624
  * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, load: string } }>}
581
625
  */
582
- export async function executeServerRoute(input) {
583
- if (!input.source || !String(input.source).trim()) {
626
+ export async function executeServerRoute({
627
+ source,
628
+ sourcePath,
629
+ params,
630
+ requestUrl,
631
+ requestMethod,
632
+ requestHeaders,
633
+ routePattern,
634
+ routeFile,
635
+ routeId,
636
+ guardOnly = false
637
+ }) {
638
+ if (!source || !String(source).trim()) {
584
639
  return {
585
640
  result: { kind: 'data', data: {} },
586
641
  trace: { guard: 'none', load: 'none' }
@@ -588,15 +643,16 @@ export async function executeServerRoute(input) {
588
643
  }
589
644
 
590
645
  const payload = await spawnNodeServerRunner({
591
- source: input.source,
592
- sourcePath: input.sourcePath,
593
- params: input.params,
594
- requestUrl: input.requestUrl || 'http://localhost/',
595
- requestMethod: input.requestMethod || 'GET',
596
- requestHeaders: sanitizeRequestHeaders(input.requestHeaders || {}),
597
- routePattern: input.routePattern || '',
598
- routeFile: input.routeFile || input.sourcePath || '',
599
- routeId: input.routeId || routeIdFromSourcePath(input.sourcePath || '')
646
+ source,
647
+ sourcePath,
648
+ params,
649
+ requestUrl: requestUrl || 'http://localhost/',
650
+ requestMethod: requestMethod || 'GET',
651
+ requestHeaders: sanitizeRequestHeaders(requestHeaders || {}),
652
+ routePattern: routePattern || '',
653
+ routeFile: routeFile || sourcePath || '',
654
+ routeId: routeId || routeIdFromSourcePath(sourcePath || ''),
655
+ guardOnly
600
656
  });
601
657
 
602
658
  if (payload === null || payload === undefined) {
@@ -702,6 +758,7 @@ function spawnNodeServerRunner(input) {
702
758
  ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
703
759
  ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
704
760
  ZENITH_SERVER_ROUTE_ID: input.routeId || '',
761
+ ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
705
762
  ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
706
763
  },
707
764
  stdio: ['ignore', 'pipe', 'pipe']
@@ -189,7 +189,7 @@ export function assertJsonSerializable(value, where = 'payload') {
189
189
  walk(value, '$');
190
190
  }
191
191
 
192
- export async function resolveRouteResult({ exports, ctx, filePath }) {
192
+ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = false }) {
193
193
  validateServerExports({ exports, filePath });
194
194
 
195
195
  const trace = {
@@ -200,6 +200,9 @@ export async function resolveRouteResult({ exports, ctx, filePath }) {
200
200
  if ('guard' in exports) {
201
201
  const guardRaw = await exports.guard(ctx);
202
202
  const guardResult = guardRaw == null ? allow() : guardRaw;
203
+ if (guardResult.kind === 'data') {
204
+ throw new Error(`[Zenith] ${filePath}: guard(ctx) returned data(payload) which is a critical invariant violation. guard() can only return allow(), redirect(), or deny(). Use load(ctx) for data injection.`);
205
+ }
203
206
  assertValidRouteResultShape(
204
207
  guardResult,
205
208
  `${filePath}: guard(ctx) return`,
@@ -211,6 +214,10 @@ export async function resolveRouteResult({ exports, ctx, filePath }) {
211
214
  }
212
215
  }
213
216
 
217
+ if (guardOnly) {
218
+ return { result: allow(), trace };
219
+ }
220
+
214
221
  let payload;
215
222
  if ('load' in exports) {
216
223
  const loadRaw = await exports.load(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/cli",
3
- "version": "0.5.0-beta.2.16",
3
+ "version": "0.5.0-beta.2.19",
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.16"
27
+ "@zenithbuild/compiler": "0.5.0-beta.2.19"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@jest/globals": "^30.2.0",