@zenithbuild/cli 0.5.0-beta.2.5 → 0.6.0
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 +5 -0
- package/dist/build.js +154 -119
- package/dist/dev-server.js +558 -53
- package/dist/index.js +75 -14
- package/dist/preview.js +323 -40
- package/dist/resolve-components.js +108 -0
- package/dist/server-contract.js +150 -11
- package/dist/ui/format.js +56 -17
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -10,11 +10,62 @@
|
|
|
10
10
|
// Minimal arg parsing. No heavy dependencies.
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
|
|
13
|
-
import { resolve, join } from 'node:path';
|
|
14
|
-
import { existsSync } from 'node:fs';
|
|
13
|
+
import { resolve, join, dirname } from 'node:path';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { createLogger } from './ui/logger.js';
|
|
16
17
|
|
|
17
18
|
const COMMANDS = ['dev', 'build', 'preview'];
|
|
19
|
+
const DEFAULT_VERSION = '0.0.0';
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
const __dirname = dirname(__filename);
|
|
22
|
+
|
|
23
|
+
function getCliVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const pkgPath = join(__dirname, '..', 'package.json');
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
27
|
+
return typeof pkg.version === 'string' ? pkg.version : DEFAULT_VERSION;
|
|
28
|
+
} catch {
|
|
29
|
+
return DEFAULT_VERSION;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printUsage(logger) {
|
|
34
|
+
logger.heading('V0');
|
|
35
|
+
logger.print('Usage:');
|
|
36
|
+
logger.print(' zenith dev [port|--port <port>] Start development server');
|
|
37
|
+
logger.print(' zenith build Build static site to /dist');
|
|
38
|
+
logger.print(' zenith preview [port|--port <port>] Preview /dist statically');
|
|
39
|
+
logger.print('');
|
|
40
|
+
logger.print('Options:');
|
|
41
|
+
logger.print(' -h, --help Show this help message');
|
|
42
|
+
logger.print(' -v, --version Print Zenith CLI version');
|
|
43
|
+
logger.print('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolvePort(args, fallback) {
|
|
47
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const flagIndex = args.findIndex((arg) => arg === '--port' || arg === '-p');
|
|
52
|
+
if (flagIndex >= 0 && args[flagIndex + 1]) {
|
|
53
|
+
const parsed = Number.parseInt(args[flagIndex + 1], 10);
|
|
54
|
+
if (Number.isFinite(parsed)) {
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const positional = args.find((arg) => /^[0-9]+$/.test(arg));
|
|
60
|
+
if (positional) {
|
|
61
|
+
const parsed = Number.parseInt(positional, 10);
|
|
62
|
+
if (Number.isFinite(parsed)) {
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
18
69
|
|
|
19
70
|
/**
|
|
20
71
|
* Load zenith.config.js from project root.
|
|
@@ -41,14 +92,20 @@ async function loadConfig(projectRoot) {
|
|
|
41
92
|
export async function cli(args, cwd) {
|
|
42
93
|
const logger = createLogger(process);
|
|
43
94
|
const command = args[0];
|
|
95
|
+
const cliVersion = getCliVersion();
|
|
96
|
+
|
|
97
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
98
|
+
logger.print(`zenith ${cliVersion}`);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
103
|
+
printUsage(logger);
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
44
106
|
|
|
45
107
|
if (!command || !COMMANDS.includes(command)) {
|
|
46
|
-
logger
|
|
47
|
-
logger.print('Usage:');
|
|
48
|
-
logger.print(' zenith dev Start development server');
|
|
49
|
-
logger.print(' zenith build Build static site to /dist');
|
|
50
|
-
logger.print(' zenith preview Preview /dist statically');
|
|
51
|
-
logger.print('');
|
|
108
|
+
printUsage(logger);
|
|
52
109
|
process.exit(command ? 1 : 0);
|
|
53
110
|
}
|
|
54
111
|
|
|
@@ -69,10 +126,13 @@ export async function cli(args, cwd) {
|
|
|
69
126
|
|
|
70
127
|
if (command === 'dev') {
|
|
71
128
|
const { createDevServer } = await import('./dev-server.js');
|
|
72
|
-
const port =
|
|
129
|
+
const port = process.env.ZENITH_DEV_PORT
|
|
130
|
+
? Number.parseInt(process.env.ZENITH_DEV_PORT, 10)
|
|
131
|
+
: resolvePort(args.slice(1), 3000);
|
|
132
|
+
const host = process.env.ZENITH_DEV_HOST || '127.0.0.1';
|
|
73
133
|
logger.info('Starting dev server...');
|
|
74
|
-
const dev = await createDevServer({ pagesDir, outDir, port, config });
|
|
75
|
-
logger.success(`Dev server running at http
|
|
134
|
+
const dev = await createDevServer({ pagesDir, outDir, port, host, config });
|
|
135
|
+
logger.success(`Dev server running at http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${dev.port}`);
|
|
76
136
|
|
|
77
137
|
// Graceful shutdown
|
|
78
138
|
process.on('SIGINT', () => {
|
|
@@ -87,10 +147,11 @@ export async function cli(args, cwd) {
|
|
|
87
147
|
|
|
88
148
|
if (command === 'preview') {
|
|
89
149
|
const { createPreviewServer } = await import('./preview.js');
|
|
90
|
-
const port =
|
|
150
|
+
const port = resolvePort(args.slice(1), 4000);
|
|
151
|
+
const host = process.env.ZENITH_PREVIEW_HOST || '127.0.0.1';
|
|
91
152
|
logger.info('Starting preview server...');
|
|
92
|
-
const preview = await createPreviewServer({ distDir: outDir, port });
|
|
93
|
-
logger.success(`Preview server running at http
|
|
153
|
+
const preview = await createPreviewServer({ distDir: outDir, port, host });
|
|
154
|
+
logger.success(`Preview server running at http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${preview.port}`);
|
|
94
155
|
|
|
95
156
|
process.on('SIGINT', () => {
|
|
96
157
|
preview.close();
|
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');
|
|
@@ -158,6 +159,55 @@ const safeRequestHeaders =
|
|
|
158
159
|
requestHeaders && typeof requestHeaders === 'object'
|
|
159
160
|
? { ...requestHeaders }
|
|
160
161
|
: {};
|
|
162
|
+
function parseCookies(rawCookieHeader) {
|
|
163
|
+
const out = Object.create(null);
|
|
164
|
+
const raw = String(rawCookieHeader || '');
|
|
165
|
+
if (!raw) return out;
|
|
166
|
+
const pairs = raw.split(';');
|
|
167
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
168
|
+
const part = pairs[i];
|
|
169
|
+
const eq = part.indexOf('=');
|
|
170
|
+
if (eq <= 0) continue;
|
|
171
|
+
const key = part.slice(0, eq).trim();
|
|
172
|
+
if (!key) continue;
|
|
173
|
+
const value = part.slice(eq + 1).trim();
|
|
174
|
+
try {
|
|
175
|
+
out[key] = decodeURIComponent(value);
|
|
176
|
+
} catch {
|
|
177
|
+
out[key] = value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
const cookieHeader = typeof safeRequestHeaders.cookie === 'string'
|
|
183
|
+
? safeRequestHeaders.cookie
|
|
184
|
+
: '';
|
|
185
|
+
const requestCookies = parseCookies(cookieHeader);
|
|
186
|
+
|
|
187
|
+
function ctxAllow() {
|
|
188
|
+
return { kind: 'allow' };
|
|
189
|
+
}
|
|
190
|
+
function ctxRedirect(location, status = 302) {
|
|
191
|
+
return {
|
|
192
|
+
kind: 'redirect',
|
|
193
|
+
location: String(location || ''),
|
|
194
|
+
status: Number.isInteger(status) ? status : 302
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function ctxDeny(status = 403, message = undefined) {
|
|
198
|
+
return {
|
|
199
|
+
kind: 'deny',
|
|
200
|
+
status: Number.isInteger(status) ? status : 403,
|
|
201
|
+
message: typeof message === 'string' ? message : undefined
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function ctxData(payload) {
|
|
205
|
+
return {
|
|
206
|
+
kind: 'data',
|
|
207
|
+
data: payload
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
161
211
|
const requestSnapshot = new Request(requestUrl, {
|
|
162
212
|
method: requestMethod,
|
|
163
213
|
headers: new Headers(safeRequestHeaders)
|
|
@@ -168,15 +218,32 @@ const routeMeta = {
|
|
|
168
218
|
pattern: routePattern,
|
|
169
219
|
file: routeFile ? path.relative(process.cwd(), routeFile) : ''
|
|
170
220
|
};
|
|
221
|
+
const routeContext = {
|
|
222
|
+
params: routeParams,
|
|
223
|
+
url: new URL(requestUrl),
|
|
224
|
+
headers: { ...safeRequestHeaders },
|
|
225
|
+
cookies: requestCookies,
|
|
226
|
+
request: requestSnapshot,
|
|
227
|
+
method: requestMethod,
|
|
228
|
+
route: routeMeta,
|
|
229
|
+
env: {},
|
|
230
|
+
auth: {
|
|
231
|
+
async getSession(_ctx) {
|
|
232
|
+
return null;
|
|
233
|
+
},
|
|
234
|
+
async requireSession(_ctx) {
|
|
235
|
+
throw ctxRedirect('/login', 302);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
allow: ctxAllow,
|
|
239
|
+
redirect: ctxRedirect,
|
|
240
|
+
deny: ctxDeny,
|
|
241
|
+
data: ctxData
|
|
242
|
+
};
|
|
171
243
|
|
|
172
244
|
const context = vm.createContext({
|
|
173
245
|
params: routeParams,
|
|
174
|
-
ctx:
|
|
175
|
-
params: routeParams,
|
|
176
|
-
url: new URL(requestUrl),
|
|
177
|
-
request: requestSnapshot,
|
|
178
|
-
route: routeMeta
|
|
179
|
-
},
|
|
246
|
+
ctx: routeContext,
|
|
180
247
|
fetch: globalThis.fetch,
|
|
181
248
|
Headers: globalThis.Headers,
|
|
182
249
|
Request: globalThis.Request,
|
|
@@ -251,11 +318,11 @@ async function linkModule(specifier, parentIdentifier) {
|
|
|
251
318
|
return loadFileModule(resolvedUrl);
|
|
252
319
|
}
|
|
253
320
|
|
|
254
|
-
const allowed = new Set(['data', 'load', 'ssr_data', 'props', 'ssr', 'prerender']);
|
|
321
|
+
const allowed = new Set(['data', 'load', 'guard', 'ssr_data', 'props', 'ssr', 'prerender']);
|
|
255
322
|
const prelude = "const params = globalThis.params;\n" +
|
|
256
323
|
"const ctx = globalThis.ctx;\n" +
|
|
257
|
-
"import {
|
|
258
|
-
"globalThis.
|
|
324
|
+
"import { resolveRouteResult } from 'zenith:server-contract';\n" +
|
|
325
|
+
"globalThis.resolveRouteResult = resolveRouteResult;\n";
|
|
259
326
|
const entryIdentifier = sourcePath
|
|
260
327
|
? pathToFileURL(sourcePath).href
|
|
261
328
|
: 'zenith:server-script';
|
|
@@ -294,13 +361,14 @@ for (const key of namespaceKeys) {
|
|
|
294
361
|
|
|
295
362
|
const exported = entryModule.namespace;
|
|
296
363
|
try {
|
|
297
|
-
const
|
|
364
|
+
const resolved = await context.resolveRouteResult({
|
|
298
365
|
exports: exported,
|
|
299
366
|
ctx: context.ctx,
|
|
300
|
-
filePath: sourcePath || 'server_script'
|
|
367
|
+
filePath: sourcePath || 'server_script',
|
|
368
|
+
guardOnly: guardOnly
|
|
301
369
|
});
|
|
302
370
|
|
|
303
|
-
process.stdout.write(JSON.stringify(
|
|
371
|
+
process.stdout.write(JSON.stringify(resolved || null));
|
|
304
372
|
} catch (error) {
|
|
305
373
|
const message = error instanceof Error ? error.message : String(error);
|
|
306
374
|
process.stdout.write(
|
|
@@ -318,17 +386,106 @@ try {
|
|
|
318
386
|
/**
|
|
319
387
|
* Create and start a preview server.
|
|
320
388
|
*
|
|
321
|
-
* @param {{ distDir: string, port?: number }} options
|
|
389
|
+
* @param {{ distDir: string, port?: number, host?: string }} options
|
|
322
390
|
* @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
|
|
323
391
|
*/
|
|
324
392
|
export async function createPreviewServer(options) {
|
|
325
|
-
const { distDir, port = 4000 } = options;
|
|
393
|
+
const { distDir, port = 4000, host = '127.0.0.1' } = options;
|
|
394
|
+
let actualPort = port;
|
|
395
|
+
|
|
396
|
+
function publicHost() {
|
|
397
|
+
if (host === '0.0.0.0' || host === '::') {
|
|
398
|
+
return '127.0.0.1';
|
|
399
|
+
}
|
|
400
|
+
return host;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function serverOrigin() {
|
|
404
|
+
return `http://${publicHost()}:${actualPort}`;
|
|
405
|
+
}
|
|
326
406
|
|
|
327
407
|
const server = createServer(async (req, res) => {
|
|
328
|
-
const
|
|
408
|
+
const requestBase = typeof req.headers.host === 'string' && req.headers.host.length > 0
|
|
409
|
+
? `http://${req.headers.host}`
|
|
410
|
+
: serverOrigin();
|
|
411
|
+
const url = new URL(req.url, requestBase);
|
|
329
412
|
|
|
330
413
|
try {
|
|
331
|
-
if (
|
|
414
|
+
if (url.pathname === '/__zenith/route-check') {
|
|
415
|
+
// Security: Require explicitly designated header to prevent public oracle probing
|
|
416
|
+
if (req.headers['x-zenith-route-check'] !== '1') {
|
|
417
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
418
|
+
res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const targetPath = String(url.searchParams.get('path') || '/');
|
|
423
|
+
|
|
424
|
+
// Security: Prevent protocol/domain injection in path
|
|
425
|
+
if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
|
|
426
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
427
|
+
res.end(JSON.stringify({ error: 'invalid_path_format' }));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const targetUrl = new URL(targetPath, url.origin);
|
|
432
|
+
if (targetUrl.origin !== url.origin) {
|
|
433
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
434
|
+
res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const routes = await loadRouteManifest(distDir);
|
|
439
|
+
const resolvedCheck = resolveRequestRoute(targetUrl, routes);
|
|
440
|
+
if (!resolvedCheck.matched || !resolvedCheck.route) {
|
|
441
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
442
|
+
res.end(JSON.stringify({ error: 'route_not_found' }));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const checkResult = await executeServerRoute({
|
|
447
|
+
source: resolvedCheck.route.server_script || '',
|
|
448
|
+
sourcePath: resolvedCheck.route.server_script_path || '',
|
|
449
|
+
params: resolvedCheck.params,
|
|
450
|
+
requestUrl: targetUrl.toString(),
|
|
451
|
+
requestMethod: req.method || 'GET',
|
|
452
|
+
requestHeaders: req.headers,
|
|
453
|
+
routePattern: resolvedCheck.route.path,
|
|
454
|
+
routeFile: resolvedCheck.route.server_script_path || '',
|
|
455
|
+
routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || ''),
|
|
456
|
+
guardOnly: true
|
|
457
|
+
});
|
|
458
|
+
// Security: Enforce relative or same-origin redirects
|
|
459
|
+
if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
|
|
460
|
+
const loc = String(checkResult.result.location || '/');
|
|
461
|
+
if (loc.includes('://') || loc.startsWith('//')) {
|
|
462
|
+
try {
|
|
463
|
+
const parsedLoc = new URL(loc);
|
|
464
|
+
if (parsedLoc.origin !== targetUrl.origin) {
|
|
465
|
+
checkResult.result.location = '/'; // Fallback to root for open redirect attempt
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
checkResult.result.location = '/';
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
res.writeHead(200, {
|
|
474
|
+
'Content-Type': 'application/json',
|
|
475
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
476
|
+
'Pragma': 'no-cache',
|
|
477
|
+
'Expires': '0',
|
|
478
|
+
'Vary': 'Cookie'
|
|
479
|
+
});
|
|
480
|
+
res.end(JSON.stringify({
|
|
481
|
+
result: checkResult?.result || checkResult,
|
|
482
|
+
routeId: resolvedCheck.route.route_id || '',
|
|
483
|
+
to: targetUrl.toString()
|
|
484
|
+
}));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (extname(url.pathname) && extname(url.pathname) !== '.html') {
|
|
332
489
|
const staticPath = resolveWithinDist(distDir, url.pathname);
|
|
333
490
|
if (!staticPath || !(await fileExists(staticPath))) {
|
|
334
491
|
throw new Error('not found');
|
|
@@ -358,11 +515,11 @@ export async function createPreviewServer(options) {
|
|
|
358
515
|
throw new Error('not found');
|
|
359
516
|
}
|
|
360
517
|
|
|
361
|
-
let
|
|
518
|
+
let ssrPayload = null;
|
|
362
519
|
if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
|
|
363
|
-
let
|
|
520
|
+
let routeExecution = null;
|
|
364
521
|
try {
|
|
365
|
-
|
|
522
|
+
routeExecution = await executeServerRoute({
|
|
366
523
|
source: resolved.route.server_script,
|
|
367
524
|
sourcePath: resolved.route.server_script_path || '',
|
|
368
525
|
params: resolved.params,
|
|
@@ -371,22 +528,48 @@ export async function createPreviewServer(options) {
|
|
|
371
528
|
requestHeaders: req.headers,
|
|
372
529
|
routePattern: resolved.route.path,
|
|
373
530
|
routeFile: resolved.route.server_script_path || '',
|
|
374
|
-
routeId: routeIdFromSourcePath(resolved.route.server_script_path || '')
|
|
531
|
+
routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
|
|
375
532
|
});
|
|
376
533
|
} catch (error) {
|
|
377
|
-
|
|
534
|
+
ssrPayload = {
|
|
378
535
|
__zenith_error: {
|
|
379
|
-
status: 500,
|
|
380
536
|
code: 'LOAD_FAILED',
|
|
381
537
|
message: error instanceof Error ? error.message : String(error)
|
|
382
538
|
}
|
|
383
539
|
};
|
|
384
540
|
}
|
|
385
|
-
|
|
386
|
-
|
|
541
|
+
|
|
542
|
+
const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
|
|
543
|
+
const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
|
|
544
|
+
console.log(`[Zenith] guard(${routeId}) -> ${trace.guard}`);
|
|
545
|
+
console.log(`[Zenith] load(${routeId}) -> ${trace.load}`);
|
|
546
|
+
|
|
547
|
+
const result = routeExecution?.result;
|
|
548
|
+
if (result && result.kind === 'redirect') {
|
|
549
|
+
const status = Number.isInteger(result.status) ? result.status : 302;
|
|
550
|
+
res.writeHead(status, {
|
|
551
|
+
Location: result.location,
|
|
552
|
+
'Cache-Control': 'no-store'
|
|
553
|
+
});
|
|
554
|
+
res.end('');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (result && result.kind === 'deny') {
|
|
558
|
+
const status = Number.isInteger(result.status) ? result.status : 403;
|
|
559
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
560
|
+
res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
564
|
+
ssrPayload = result.data;
|
|
387
565
|
}
|
|
388
566
|
}
|
|
389
567
|
|
|
568
|
+
let html = await readFile(htmlPath, 'utf8');
|
|
569
|
+
if (ssrPayload) {
|
|
570
|
+
html = injectSsrPayload(html, ssrPayload);
|
|
571
|
+
}
|
|
572
|
+
|
|
390
573
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
391
574
|
res.end(html);
|
|
392
575
|
} catch {
|
|
@@ -396,8 +579,8 @@ export async function createPreviewServer(options) {
|
|
|
396
579
|
});
|
|
397
580
|
|
|
398
581
|
return new Promise((resolveServer) => {
|
|
399
|
-
server.listen(port, () => {
|
|
400
|
-
|
|
582
|
+
server.listen(port, host, () => {
|
|
583
|
+
actualPort = server.address().port;
|
|
401
584
|
resolveServer({
|
|
402
585
|
server,
|
|
403
586
|
port: actualPort,
|
|
@@ -416,6 +599,13 @@ export async function createPreviewServer(options) {
|
|
|
416
599
|
* server_script?: string | null;
|
|
417
600
|
* server_script_path?: string | null;
|
|
418
601
|
* prerender?: boolean;
|
|
602
|
+
* route_id?: string;
|
|
603
|
+
* pattern?: string;
|
|
604
|
+
* params_shape?: Record<string, string>;
|
|
605
|
+
* has_guard?: boolean;
|
|
606
|
+
* has_load?: boolean;
|
|
607
|
+
* guard_module_ref?: string | null;
|
|
608
|
+
* load_module_ref?: string | null;
|
|
419
609
|
* }} PreviewRoute
|
|
420
610
|
*/
|
|
421
611
|
|
|
@@ -446,28 +636,120 @@ export const matchRoute = matchManifestRoute;
|
|
|
446
636
|
|
|
447
637
|
/**
|
|
448
638
|
* @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<
|
|
639
|
+
* @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, load: string } }>}
|
|
450
640
|
*/
|
|
451
|
-
export async function
|
|
641
|
+
export async function executeServerRoute({
|
|
642
|
+
source,
|
|
643
|
+
sourcePath,
|
|
644
|
+
params,
|
|
645
|
+
requestUrl,
|
|
646
|
+
requestMethod,
|
|
647
|
+
requestHeaders,
|
|
648
|
+
routePattern,
|
|
649
|
+
routeFile,
|
|
650
|
+
routeId,
|
|
651
|
+
guardOnly = false
|
|
652
|
+
}) {
|
|
653
|
+
if (!source || !String(source).trim()) {
|
|
654
|
+
return {
|
|
655
|
+
result: { kind: 'data', data: {} },
|
|
656
|
+
trace: { guard: 'none', load: 'none' }
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
452
660
|
const payload = await spawnNodeServerRunner({
|
|
453
|
-
source
|
|
454
|
-
sourcePath
|
|
455
|
-
params
|
|
456
|
-
requestUrl:
|
|
457
|
-
requestMethod:
|
|
458
|
-
requestHeaders: sanitizeRequestHeaders(
|
|
459
|
-
routePattern:
|
|
460
|
-
routeFile:
|
|
461
|
-
routeId:
|
|
661
|
+
source,
|
|
662
|
+
sourcePath,
|
|
663
|
+
params,
|
|
664
|
+
requestUrl: requestUrl || 'http://localhost/',
|
|
665
|
+
requestMethod: requestMethod || 'GET',
|
|
666
|
+
requestHeaders: sanitizeRequestHeaders(requestHeaders || {}),
|
|
667
|
+
routePattern: routePattern || '',
|
|
668
|
+
routeFile: routeFile || sourcePath || '',
|
|
669
|
+
routeId: routeId || routeIdFromSourcePath(sourcePath || ''),
|
|
670
|
+
guardOnly
|
|
462
671
|
});
|
|
463
672
|
|
|
464
673
|
if (payload === null || payload === undefined) {
|
|
465
|
-
return
|
|
674
|
+
return {
|
|
675
|
+
result: { kind: 'data', data: {} },
|
|
676
|
+
trace: { guard: 'none', load: 'none' }
|
|
677
|
+
};
|
|
466
678
|
}
|
|
467
679
|
if (typeof payload !== 'object' || Array.isArray(payload)) {
|
|
468
680
|
throw new Error('[zenith-preview] server script payload must be an object');
|
|
469
681
|
}
|
|
470
|
-
|
|
682
|
+
|
|
683
|
+
const errorEnvelope = payload.__zenith_error;
|
|
684
|
+
if (errorEnvelope && typeof errorEnvelope === 'object') {
|
|
685
|
+
return {
|
|
686
|
+
result: {
|
|
687
|
+
kind: 'deny',
|
|
688
|
+
status: 500,
|
|
689
|
+
message: String(errorEnvelope.message || 'Server route execution failed')
|
|
690
|
+
},
|
|
691
|
+
trace: { guard: 'none', load: 'deny' }
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const result = payload.result;
|
|
696
|
+
const trace = payload.trace;
|
|
697
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && typeof result.kind === 'string') {
|
|
698
|
+
return {
|
|
699
|
+
result,
|
|
700
|
+
trace: trace && typeof trace === 'object'
|
|
701
|
+
? {
|
|
702
|
+
guard: String(trace.guard || 'none'),
|
|
703
|
+
load: String(trace.load || 'none')
|
|
704
|
+
}
|
|
705
|
+
: { guard: 'none', load: 'none' }
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
result: {
|
|
711
|
+
kind: 'data',
|
|
712
|
+
data: payload
|
|
713
|
+
},
|
|
714
|
+
trace: { guard: 'none', load: 'data' }
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* @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
|
|
720
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
721
|
+
*/
|
|
722
|
+
export async function executeServerScript(input) {
|
|
723
|
+
const execution = await executeServerRoute(input);
|
|
724
|
+
const result = execution?.result;
|
|
725
|
+
if (!result || typeof result !== 'object') {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
if (result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
729
|
+
return result.data;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (result.kind === 'redirect') {
|
|
733
|
+
return {
|
|
734
|
+
__zenith_error: {
|
|
735
|
+
status: Number.isInteger(result.status) ? result.status : 302,
|
|
736
|
+
code: 'REDIRECT',
|
|
737
|
+
message: `Redirect to ${String(result.location || '')}`
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (result.kind === 'deny') {
|
|
743
|
+
return {
|
|
744
|
+
__zenith_error: {
|
|
745
|
+
status: Number.isInteger(result.status) ? result.status : 403,
|
|
746
|
+
code: 'ACCESS_DENIED',
|
|
747
|
+
message: String(result.message || 'Access denied')
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return {};
|
|
471
753
|
}
|
|
472
754
|
|
|
473
755
|
/**
|
|
@@ -491,6 +773,7 @@ function spawnNodeServerRunner(input) {
|
|
|
491
773
|
ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
|
|
492
774
|
ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
|
|
493
775
|
ZENITH_SERVER_ROUTE_ID: input.routeId || '',
|
|
776
|
+
ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
|
|
494
777
|
ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
|
|
495
778
|
},
|
|
496
779
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
@@ -609,7 +892,7 @@ export function resolveWithinDist(distDir, requestPath) {
|
|
|
609
892
|
*/
|
|
610
893
|
function sanitizeRequestHeaders(headers) {
|
|
611
894
|
const out = Object.create(null);
|
|
612
|
-
const denyExact = new Set(['
|
|
895
|
+
const denyExact = new Set(['proxy-authorization', 'set-cookie']);
|
|
613
896
|
const denyPrefixes = ['x-forwarded-', 'cf-'];
|
|
614
897
|
for (const [rawKey, rawValue] of Object.entries(headers || {})) {
|
|
615
898
|
const key = String(rawKey || '').toLowerCase();
|