@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 +71 -4
- package/dist/dev-server.js +174 -36
- package/dist/preview.js +80 -23
- package/dist/server-contract.js +8 -1
- package/package.json +2 -2
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:
|
|
502
|
-
` Example: move
|
|
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 || [];
|
package/dist/dev-server.js
CHANGED
|
@@ -35,21 +35,8 @@ const MIME_TYPES = {
|
|
|
35
35
|
'.svg': 'image/svg+xml'
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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-
|
|
84
|
-
'Connection': 'keep-alive'
|
|
100
|
+
'Cache-Control': 'no-store',
|
|
101
|
+
'Connection': 'keep-alive',
|
|
102
|
+
'X-Zenith-Deprecated': 'true'
|
|
85
103
|
});
|
|
86
|
-
|
|
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.
|
|
120
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 },
|
|
340
|
+
_watcher = watch(pagesDir, { recursive: true }, (eventType, filename) => {
|
|
245
341
|
if (!filename) return;
|
|
246
342
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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.
|
|
420
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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(
|
|
583
|
-
|
|
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
|
|
592
|
-
sourcePath
|
|
593
|
-
params
|
|
594
|
-
requestUrl:
|
|
595
|
-
requestMethod:
|
|
596
|
-
requestHeaders: sanitizeRequestHeaders(
|
|
597
|
-
routePattern:
|
|
598
|
-
routeFile:
|
|
599
|
-
routeId:
|
|
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']
|
package/dist/server-contract.js
CHANGED
|
@@ -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.
|
|
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.
|
|
27
|
+
"@zenithbuild/compiler": "0.5.0-beta.2.19"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@jest/globals": "^30.2.0",
|