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

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 || [];
@@ -5,16 +5,16 @@
5
5
  //
6
6
  // - Compiles pages on demand
7
7
  // - Rebuilds on file change
8
- // - Injects HMR client script
8
+ // - Exposes V1 HMR endpoints consumed by runtime dev client
9
9
  // - Server route resolution uses manifest matching
10
10
  //
11
11
  // V0: Uses Node.js http module + fs.watch. No external deps.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
14
  import { createServer } from 'node:http';
15
- import { watch } from 'node:fs';
15
+ import { existsSync, watch } from 'node:fs';
16
16
  import { readFile } from 'node:fs/promises';
17
- import { join, extname } from 'node:path';
17
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
18
18
  import { build } from './build.js';
19
19
  import {
20
20
  executeServerRoute,
@@ -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.
@@ -65,25 +52,66 @@ export async function createDevServer(options) {
65
52
  config = {}
66
53
  } = options;
67
54
 
55
+ const resolvedPagesDir = resolve(pagesDir);
56
+ const resolvedOutDir = resolve(outDir);
57
+ const resolvedOutDirTmp = resolve(dirname(resolvedOutDir), `${basename(resolvedOutDir)}.tmp`);
58
+ const pagesParentDir = dirname(resolvedPagesDir);
59
+ const projectRoot = basename(pagesParentDir) === 'src'
60
+ ? dirname(pagesParentDir)
61
+ : pagesParentDir;
62
+ const watchRoots = new Set([pagesParentDir]);
63
+
68
64
  /** @type {import('http').ServerResponse[]} */
69
65
  const hmrClients = [];
70
- let _watcher = null;
66
+ /** @type {import('fs').FSWatcher[]} */
67
+ let _watchers = [];
68
+
69
+ let buildId = 0;
70
+ let buildStatus = 'ok'; // 'ok' | 'error' | 'building'
71
+ let lastBuildMs = Date.now();
72
+ let durationMs = 0;
73
+ let buildError = null;
74
+
75
+ // We can't know the exact CSS hashed filename here easily without parsing the dist manifest,
76
+ // but the runtime handles standard HMR updates via generic fetch if we pass a timestamp,
77
+ // or we can pass an empty string and rely on the client's `swapStylesheet`.
78
+ let currentCssHref = '';
79
+
80
+ function _broadcastEvent(type, payload = {}) {
81
+ const data = JSON.stringify({
82
+ buildId,
83
+ ...payload
84
+ });
85
+ for (const client of hmrClients) {
86
+ try {
87
+ client.write(`event: ${type}\ndata: ${data}\n\n`);
88
+ } catch {
89
+ // client disconnected
90
+ }
91
+ }
92
+ }
71
93
 
72
94
  // Initial build
73
- await build({ pagesDir, outDir, config });
95
+ try {
96
+ await build({ pagesDir, outDir, config });
97
+ } catch (err) {
98
+ buildStatus = 'error';
99
+ buildError = { message: err instanceof Error ? err.message : String(err) };
100
+ }
74
101
 
75
102
  const server = createServer(async (req, res) => {
76
103
  const url = new URL(req.url, `http://localhost:${port}`);
77
104
  let pathname = url.pathname;
78
105
 
79
- // HMR endpoint
106
+ // Legacy HMR endpoint (deprecated but kept alive to avoid breaking old caches instantly)
80
107
  if (pathname === '/__zenith_hmr') {
81
108
  res.writeHead(200, {
82
109
  'Content-Type': 'text/event-stream',
83
- 'Cache-Control': 'no-cache',
84
- 'Connection': 'keep-alive'
110
+ 'Cache-Control': 'no-store',
111
+ 'Connection': 'keep-alive',
112
+ 'X-Zenith-Deprecated': 'true'
85
113
  });
86
- // Flush headers by sending initial comment
114
+ console.warn('[zenith] Warning: /__zenith_hmr is legacy; use /__zenith_dev/events');
87
115
  res.write(': connected\n\n');
88
116
  hmrClients.push(res);
89
117
  req.on('close', () => {
@@ -93,10 +121,65 @@ export async function createDevServer(options) {
93
121
  return;
94
122
  }
95
123
 
124
+ // V1 Dev State Endpoint
125
+ if (pathname === '/__zenith_dev/state') {
126
+ res.writeHead(200, {
127
+ 'Content-Type': 'application/json',
128
+ 'Cache-Control': 'no-store'
129
+ });
130
+ res.end(JSON.stringify({
131
+ serverUrl: `http://localhost:${port}`,
132
+ buildId,
133
+ status: buildStatus,
134
+ lastBuildMs,
135
+ durationMs,
136
+ cssHref: currentCssHref,
137
+ error: buildError
138
+ }));
139
+ return;
140
+ }
141
+
142
+ // V1 Dev Events Endpoint (SSE)
143
+ if (pathname === '/__zenith_dev/events') {
144
+ res.writeHead(200, {
145
+ 'Content-Type': 'text/event-stream',
146
+ 'Cache-Control': 'no-store',
147
+ 'Connection': 'keep-alive'
148
+ });
149
+ res.write('event: connected\ndata: {}\n\n');
150
+ hmrClients.push(res);
151
+ req.on('close', () => {
152
+ const idx = hmrClients.indexOf(res);
153
+ if (idx !== -1) hmrClients.splice(idx, 1);
154
+ });
155
+ return;
156
+ }
157
+
96
158
  if (pathname === '/__zenith/route-check') {
97
159
  try {
160
+ // Security: Require explicitly designated header to prevent public oracle probing
161
+ if (req.headers['x-zenith-route-check'] !== '1') {
162
+ res.writeHead(403, { 'Content-Type': 'application/json' });
163
+ res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
164
+ return;
165
+ }
166
+
98
167
  const targetPath = String(url.searchParams.get('path') || '/');
168
+
169
+ // Security: Prevent protocol/domain injection in path
170
+ if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
171
+ res.writeHead(400, { 'Content-Type': 'application/json' });
172
+ res.end(JSON.stringify({ error: 'invalid_path_format' }));
173
+ return;
174
+ }
175
+
99
176
  const targetUrl = new URL(targetPath, `http://localhost:${port}`);
177
+ if (targetUrl.origin !== `http://localhost:${port}`) {
178
+ res.writeHead(400, { 'Content-Type': 'application/json' });
179
+ res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
180
+ return;
181
+ }
182
+
100
183
  const routes = await loadRouteManifest(outDir);
101
184
  const resolvedCheck = resolveRequestRoute(targetUrl, routes);
102
185
  if (!resolvedCheck.matched || !resolvedCheck.route) {
@@ -114,10 +197,36 @@ export async function createDevServer(options) {
114
197
  requestHeaders: req.headers,
115
198
  routePattern: resolvedCheck.route.path,
116
199
  routeFile: resolvedCheck.route.server_script_path || '',
117
- routeId: resolvedCheck.route.route_id || ''
200
+ routeId: resolvedCheck.route.route_id || '',
201
+ guardOnly: true
202
+ });
203
+ // Security: Enforce relative or same-origin redirects
204
+ if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
205
+ const loc = String(checkResult.result.location || '/');
206
+ if (loc.includes('://') || loc.startsWith('//')) {
207
+ try {
208
+ const parsedLoc = new URL(loc);
209
+ if (parsedLoc.origin !== targetUrl.origin) {
210
+ checkResult.result.location = '/'; // Fallback to root for open redirect attempt
211
+ }
212
+ } catch {
213
+ checkResult.result.location = '/';
214
+ }
215
+ }
216
+ }
217
+
218
+ res.writeHead(200, {
219
+ 'Content-Type': 'application/json',
220
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
221
+ 'Pragma': 'no-cache',
222
+ 'Expires': '0',
223
+ 'Vary': 'Cookie'
118
224
  });
119
- res.writeHead(200, { 'Content-Type': 'application/json' });
120
- res.end(JSON.stringify(checkResult));
225
+ res.end(JSON.stringify({
226
+ result: checkResult?.result || checkResult,
227
+ routeId: resolvedCheck.route.route_id || '',
228
+ to: targetUrl.toString()
229
+ }));
121
230
  return;
122
231
  } catch {
123
232
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -171,15 +280,10 @@ export async function createDevServer(options) {
171
280
  routeId: resolved.route.route_id || ''
172
281
  });
173
282
  } catch (error) {
174
- routeExecution = {
175
- result: {
176
- kind: 'deny',
177
- status: 500,
283
+ ssrPayload = {
284
+ __zenith_error: {
285
+ code: 'LOAD_FAILED',
178
286
  message: error instanceof Error ? error.message : String(error)
179
- },
180
- trace: {
181
- guard: 'none',
182
- load: 'deny'
183
287
  }
184
288
  };
185
289
  }
@@ -214,7 +318,6 @@ export async function createDevServer(options) {
214
318
  if (ssrPayload) {
215
319
  content = injectSsrPayload(content, ssrPayload);
216
320
  }
217
- content = content.replace('</body>', `${HMR_CLIENT_SCRIPT}</body>`);
218
321
  res.writeHead(200, { 'Content-Type': 'text/html' });
219
322
  res.end(content);
220
323
  } catch {
@@ -236,20 +339,111 @@ export async function createDevServer(options) {
236
339
  }
237
340
  }
238
341
 
342
+ let _buildDebounce = null;
343
+ let _queuedFiles = new Set();
344
+
345
+ function _isWithin(parent, child) {
346
+ const rel = relative(parent, child);
347
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
348
+ }
349
+
350
+ function _toDisplayPath(absPath) {
351
+ const rel = relative(projectRoot, absPath);
352
+ if (rel === '') return '.';
353
+ if (!rel.startsWith('..') && !isAbsolute(rel)) {
354
+ return rel;
355
+ }
356
+ return absPath;
357
+ }
358
+
359
+ function _shouldIgnoreChange(absPath) {
360
+ if (_isWithin(resolvedOutDir, absPath)) {
361
+ return true;
362
+ }
363
+ if (_isWithin(resolvedOutDirTmp, absPath)) {
364
+ return true;
365
+ }
366
+ const rel = relative(projectRoot, absPath);
367
+ if (rel.startsWith('..') || isAbsolute(rel)) {
368
+ return false;
369
+ }
370
+ const segments = rel.split(/[\\/]+/g);
371
+ return segments.includes('node_modules')
372
+ || segments.includes('.git')
373
+ || segments.includes('.zenith')
374
+ || segments.includes('target')
375
+ || segments.includes('.turbo');
376
+ }
377
+
239
378
  /**
240
- * Start watching the pages directory for changes.
379
+ * Start watching source roots for changes.
241
380
  */
242
381
  function _startWatcher() {
243
- try {
244
- _watcher = watch(pagesDir, { recursive: true }, async (eventType, filename) => {
245
- if (!filename) return;
382
+ const queueRebuild = () => {
383
+ if (_buildDebounce !== null) {
384
+ clearTimeout(_buildDebounce);
385
+ }
246
386
 
247
- // Rebuild
248
- await build({ pagesDir, outDir, config });
249
- _broadcastReload();
250
- });
251
- } catch {
252
- // fs.watch may not support recursive on all platforms
387
+ _buildDebounce = setTimeout(async () => {
388
+ _buildDebounce = null;
389
+ const changed = Array.from(_queuedFiles).map(_toDisplayPath).sort();
390
+ _queuedFiles.clear();
391
+
392
+ buildId++;
393
+ buildStatus = 'building';
394
+ _broadcastEvent('build_start', { changedFiles: changed });
395
+
396
+ const startTime = Date.now();
397
+ try {
398
+ await build({ pagesDir, outDir, config });
399
+ buildStatus = 'ok';
400
+ buildError = null;
401
+ lastBuildMs = Date.now();
402
+ durationMs = lastBuildMs - startTime;
403
+
404
+ _broadcastEvent('build_complete', {
405
+ durationMs,
406
+ status: buildStatus
407
+ });
408
+
409
+ const onlyCss = changed.length > 0 && changed.every((f) => f.endsWith('.css'));
410
+ if (onlyCss) {
411
+ // Let the client fetch the updated CSS automatically
412
+ _broadcastEvent('css_update', {});
413
+ } else {
414
+ _broadcastEvent('reload', {});
415
+ }
416
+ } catch (err) {
417
+ const fullError = err instanceof Error ? err.message : String(err);
418
+ buildStatus = 'error';
419
+ buildError = { message: fullError.length > 10000 ? fullError.slice(0, 10000) + '... (truncated)' : fullError };
420
+ lastBuildMs = Date.now();
421
+ durationMs = lastBuildMs - startTime;
422
+
423
+ _broadcastEvent('build_error', buildError);
424
+ }
425
+ }, 50);
426
+ };
427
+
428
+ const roots = Array.from(watchRoots);
429
+ for (const root of roots) {
430
+ if (!existsSync(root)) continue;
431
+ try {
432
+ const watcher = watch(root, { recursive: true }, (_eventType, filename) => {
433
+ if (!filename) {
434
+ return;
435
+ }
436
+ const changedPath = resolve(root, String(filename));
437
+ if (_shouldIgnoreChange(changedPath)) {
438
+ return;
439
+ }
440
+ _queuedFiles.add(changedPath);
441
+ queueRebuild();
442
+ });
443
+ _watchers.push(watcher);
444
+ } catch {
445
+ // fs.watch recursive may not be supported on this platform/root
446
+ }
253
447
  }
254
448
  }
255
449
 
@@ -262,10 +456,14 @@ export async function createDevServer(options) {
262
456
  server,
263
457
  port: actualPort,
264
458
  close: () => {
265
- if (_watcher) {
266
- _watcher.close();
267
- _watcher = null;
459
+ for (const watcher of _watchers) {
460
+ try {
461
+ watcher.close();
462
+ } catch {
463
+ // ignore close errors
464
+ }
268
465
  }
466
+ _watchers = [];
269
467
  for (const client of hmrClients) {
270
468
  try { client.end(); } catch { }
271
469
  }
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.20",
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.20"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@jest/globals": "^30.2.0",