@zenithbuild/cli 0.5.0-beta.2.6 → 0.6.2
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 +284 -126
- package/dist/dev-server.js +607 -55
- package/dist/index.js +84 -23
- package/dist/preview.js +332 -41
- package/dist/resolve-components.js +108 -0
- package/dist/server-contract.js +150 -11
- package/dist/ui/env.js +17 -1
- package/dist/ui/format.js +131 -54
- package/dist/ui/logger.js +239 -74
- package/package.json +4 -3
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';
|
|
15
|
-
import {
|
|
13
|
+
import { resolve, join, dirname } from 'node:path';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { createZenithLogger } 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.
|
|
@@ -39,16 +90,22 @@ async function loadConfig(projectRoot) {
|
|
|
39
90
|
* @param {string} [cwd] - Working directory override
|
|
40
91
|
*/
|
|
41
92
|
export async function cli(args, cwd) {
|
|
42
|
-
const logger =
|
|
93
|
+
const logger = createZenithLogger(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
|
|
|
@@ -61,18 +118,21 @@ export async function cli(args, cwd) {
|
|
|
61
118
|
|
|
62
119
|
if (command === 'build') {
|
|
63
120
|
const { build } = await import('./build.js');
|
|
64
|
-
logger.
|
|
65
|
-
const result = await build({ pagesDir, outDir, config });
|
|
66
|
-
logger.
|
|
67
|
-
logger.summary([{ label: 'Output', value: './dist' }]);
|
|
121
|
+
logger.build('Building…');
|
|
122
|
+
const result = await build({ pagesDir, outDir, config, logger, showBundlerInfo: false });
|
|
123
|
+
logger.ok(`Built ${result.pages} page(s), ${result.assets.length} asset(s)`);
|
|
124
|
+
logger.summary([{ label: 'Output', value: './dist' }], 'BUILD');
|
|
68
125
|
}
|
|
69
126
|
|
|
70
127
|
if (command === 'dev') {
|
|
71
128
|
const { createDevServer } = await import('./dev-server.js');
|
|
72
|
-
const port =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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';
|
|
133
|
+
logger.dev('Starting dev server…');
|
|
134
|
+
const dev = await createDevServer({ pagesDir, outDir, port, host, config, logger });
|
|
135
|
+
logger.ok(`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 =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
150
|
+
const port = resolvePort(args.slice(1), 4000);
|
|
151
|
+
const host = process.env.ZENITH_PREVIEW_HOST || '127.0.0.1';
|
|
152
|
+
logger.dev('Starting preview server…');
|
|
153
|
+
const preview = await createPreviewServer({ distDir: outDir, port, host, logger });
|
|
154
|
+
logger.ok(`http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${preview.port}`);
|
|
94
155
|
|
|
95
156
|
process.on('SIGINT', () => {
|
|
96
157
|
preview.close();
|
|
@@ -111,7 +172,7 @@ const isDirectRun = process.argv[1] && (
|
|
|
111
172
|
|
|
112
173
|
if (isDirectRun) {
|
|
113
174
|
cli(process.argv.slice(2)).catch((error) => {
|
|
114
|
-
const logger =
|
|
175
|
+
const logger = createZenithLogger(process);
|
|
115
176
|
logger.error(error);
|
|
116
177
|
process.exit(1);
|
|
117
178
|
});
|
package/dist/preview.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createServer } from 'node:http';
|
|
|
14
14
|
import { access, readFile } from 'node:fs/promises';
|
|
15
15
|
import { extname, join, normalize, resolve, sep, dirname } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { createSilentLogger } from './ui/logger.js';
|
|
17
18
|
import {
|
|
18
19
|
compareRouteSpecificity,
|
|
19
20
|
matchRoute as matchManifestRoute,
|
|
@@ -47,6 +48,7 @@ const requestHeaders = JSON.parse(process.env.ZENITH_SERVER_REQUEST_HEADERS || '
|
|
|
47
48
|
const routePattern = process.env.ZENITH_SERVER_ROUTE_PATTERN || '';
|
|
48
49
|
const routeFile = process.env.ZENITH_SERVER_ROUTE_FILE || sourcePath || '';
|
|
49
50
|
const routeId = process.env.ZENITH_SERVER_ROUTE_ID || routePattern || '';
|
|
51
|
+
const guardOnly = process.env.ZENITH_SERVER_GUARD_ONLY === '1';
|
|
50
52
|
|
|
51
53
|
if (!source.trim()) {
|
|
52
54
|
process.stdout.write('null');
|
|
@@ -158,6 +160,55 @@ const safeRequestHeaders =
|
|
|
158
160
|
requestHeaders && typeof requestHeaders === 'object'
|
|
159
161
|
? { ...requestHeaders }
|
|
160
162
|
: {};
|
|
163
|
+
function parseCookies(rawCookieHeader) {
|
|
164
|
+
const out = Object.create(null);
|
|
165
|
+
const raw = String(rawCookieHeader || '');
|
|
166
|
+
if (!raw) return out;
|
|
167
|
+
const pairs = raw.split(';');
|
|
168
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
169
|
+
const part = pairs[i];
|
|
170
|
+
const eq = part.indexOf('=');
|
|
171
|
+
if (eq <= 0) continue;
|
|
172
|
+
const key = part.slice(0, eq).trim();
|
|
173
|
+
if (!key) continue;
|
|
174
|
+
const value = part.slice(eq + 1).trim();
|
|
175
|
+
try {
|
|
176
|
+
out[key] = decodeURIComponent(value);
|
|
177
|
+
} catch {
|
|
178
|
+
out[key] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
const cookieHeader = typeof safeRequestHeaders.cookie === 'string'
|
|
184
|
+
? safeRequestHeaders.cookie
|
|
185
|
+
: '';
|
|
186
|
+
const requestCookies = parseCookies(cookieHeader);
|
|
187
|
+
|
|
188
|
+
function ctxAllow() {
|
|
189
|
+
return { kind: 'allow' };
|
|
190
|
+
}
|
|
191
|
+
function ctxRedirect(location, status = 302) {
|
|
192
|
+
return {
|
|
193
|
+
kind: 'redirect',
|
|
194
|
+
location: String(location || ''),
|
|
195
|
+
status: Number.isInteger(status) ? status : 302
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function ctxDeny(status = 403, message = undefined) {
|
|
199
|
+
return {
|
|
200
|
+
kind: 'deny',
|
|
201
|
+
status: Number.isInteger(status) ? status : 403,
|
|
202
|
+
message: typeof message === 'string' ? message : undefined
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function ctxData(payload) {
|
|
206
|
+
return {
|
|
207
|
+
kind: 'data',
|
|
208
|
+
data: payload
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
161
212
|
const requestSnapshot = new Request(requestUrl, {
|
|
162
213
|
method: requestMethod,
|
|
163
214
|
headers: new Headers(safeRequestHeaders)
|
|
@@ -168,15 +219,32 @@ const routeMeta = {
|
|
|
168
219
|
pattern: routePattern,
|
|
169
220
|
file: routeFile ? path.relative(process.cwd(), routeFile) : ''
|
|
170
221
|
};
|
|
222
|
+
const routeContext = {
|
|
223
|
+
params: routeParams,
|
|
224
|
+
url: new URL(requestUrl),
|
|
225
|
+
headers: { ...safeRequestHeaders },
|
|
226
|
+
cookies: requestCookies,
|
|
227
|
+
request: requestSnapshot,
|
|
228
|
+
method: requestMethod,
|
|
229
|
+
route: routeMeta,
|
|
230
|
+
env: {},
|
|
231
|
+
auth: {
|
|
232
|
+
async getSession(_ctx) {
|
|
233
|
+
return null;
|
|
234
|
+
},
|
|
235
|
+
async requireSession(_ctx) {
|
|
236
|
+
throw ctxRedirect('/login', 302);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
allow: ctxAllow,
|
|
240
|
+
redirect: ctxRedirect,
|
|
241
|
+
deny: ctxDeny,
|
|
242
|
+
data: ctxData
|
|
243
|
+
};
|
|
171
244
|
|
|
172
245
|
const context = vm.createContext({
|
|
173
246
|
params: routeParams,
|
|
174
|
-
ctx:
|
|
175
|
-
params: routeParams,
|
|
176
|
-
url: new URL(requestUrl),
|
|
177
|
-
request: requestSnapshot,
|
|
178
|
-
route: routeMeta
|
|
179
|
-
},
|
|
247
|
+
ctx: routeContext,
|
|
180
248
|
fetch: globalThis.fetch,
|
|
181
249
|
Headers: globalThis.Headers,
|
|
182
250
|
Request: globalThis.Request,
|
|
@@ -251,11 +319,11 @@ async function linkModule(specifier, parentIdentifier) {
|
|
|
251
319
|
return loadFileModule(resolvedUrl);
|
|
252
320
|
}
|
|
253
321
|
|
|
254
|
-
const allowed = new Set(['data', 'load', 'ssr_data', 'props', 'ssr', 'prerender']);
|
|
322
|
+
const allowed = new Set(['data', 'load', 'guard', 'ssr_data', 'props', 'ssr', 'prerender']);
|
|
255
323
|
const prelude = "const params = globalThis.params;\n" +
|
|
256
324
|
"const ctx = globalThis.ctx;\n" +
|
|
257
|
-
"import {
|
|
258
|
-
"globalThis.
|
|
325
|
+
"import { resolveRouteResult } from 'zenith:server-contract';\n" +
|
|
326
|
+
"globalThis.resolveRouteResult = resolveRouteResult;\n";
|
|
259
327
|
const entryIdentifier = sourcePath
|
|
260
328
|
? pathToFileURL(sourcePath).href
|
|
261
329
|
: 'zenith:server-script';
|
|
@@ -294,13 +362,14 @@ for (const key of namespaceKeys) {
|
|
|
294
362
|
|
|
295
363
|
const exported = entryModule.namespace;
|
|
296
364
|
try {
|
|
297
|
-
const
|
|
365
|
+
const resolved = await context.resolveRouteResult({
|
|
298
366
|
exports: exported,
|
|
299
367
|
ctx: context.ctx,
|
|
300
|
-
filePath: sourcePath || 'server_script'
|
|
368
|
+
filePath: sourcePath || 'server_script',
|
|
369
|
+
guardOnly: guardOnly
|
|
301
370
|
});
|
|
302
371
|
|
|
303
|
-
process.stdout.write(JSON.stringify(
|
|
372
|
+
process.stdout.write(JSON.stringify(resolved || null));
|
|
304
373
|
} catch (error) {
|
|
305
374
|
const message = error instanceof Error ? error.message : String(error);
|
|
306
375
|
process.stdout.write(
|
|
@@ -318,17 +387,108 @@ try {
|
|
|
318
387
|
/**
|
|
319
388
|
* Create and start a preview server.
|
|
320
389
|
*
|
|
321
|
-
* @param {{ distDir: string, port?: number }} options
|
|
390
|
+
* @param {{ distDir: string, port?: number, host?: string, logger?: object | null }} options
|
|
322
391
|
* @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
|
|
323
392
|
*/
|
|
324
393
|
export async function createPreviewServer(options) {
|
|
325
|
-
const { distDir, port = 4000 } = options;
|
|
394
|
+
const { distDir, port = 4000, host = '127.0.0.1', logger: providedLogger = null } = options;
|
|
395
|
+
const logger = providedLogger || createSilentLogger();
|
|
396
|
+
const verboseLogging = logger.mode?.logLevel === 'verbose';
|
|
397
|
+
let actualPort = port;
|
|
398
|
+
|
|
399
|
+
function publicHost() {
|
|
400
|
+
if (host === '0.0.0.0' || host === '::') {
|
|
401
|
+
return '127.0.0.1';
|
|
402
|
+
}
|
|
403
|
+
return host;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function serverOrigin() {
|
|
407
|
+
return `http://${publicHost()}:${actualPort}`;
|
|
408
|
+
}
|
|
326
409
|
|
|
327
410
|
const server = createServer(async (req, res) => {
|
|
328
|
-
const
|
|
411
|
+
const requestBase = typeof req.headers.host === 'string' && req.headers.host.length > 0
|
|
412
|
+
? `http://${req.headers.host}`
|
|
413
|
+
: serverOrigin();
|
|
414
|
+
const url = new URL(req.url, requestBase);
|
|
329
415
|
|
|
330
416
|
try {
|
|
331
|
-
if (
|
|
417
|
+
if (url.pathname === '/__zenith/route-check') {
|
|
418
|
+
// Security: Require explicitly designated header to prevent public oracle probing
|
|
419
|
+
if (req.headers['x-zenith-route-check'] !== '1') {
|
|
420
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
421
|
+
res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const targetPath = String(url.searchParams.get('path') || '/');
|
|
426
|
+
|
|
427
|
+
// Security: Prevent protocol/domain injection in path
|
|
428
|
+
if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
|
|
429
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
430
|
+
res.end(JSON.stringify({ error: 'invalid_path_format' }));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const targetUrl = new URL(targetPath, url.origin);
|
|
435
|
+
if (targetUrl.origin !== url.origin) {
|
|
436
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
437
|
+
res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const routes = await loadRouteManifest(distDir);
|
|
442
|
+
const resolvedCheck = resolveRequestRoute(targetUrl, routes);
|
|
443
|
+
if (!resolvedCheck.matched || !resolvedCheck.route) {
|
|
444
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
445
|
+
res.end(JSON.stringify({ error: 'route_not_found' }));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const checkResult = await executeServerRoute({
|
|
450
|
+
source: resolvedCheck.route.server_script || '',
|
|
451
|
+
sourcePath: resolvedCheck.route.server_script_path || '',
|
|
452
|
+
params: resolvedCheck.params,
|
|
453
|
+
requestUrl: targetUrl.toString(),
|
|
454
|
+
requestMethod: req.method || 'GET',
|
|
455
|
+
requestHeaders: req.headers,
|
|
456
|
+
routePattern: resolvedCheck.route.path,
|
|
457
|
+
routeFile: resolvedCheck.route.server_script_path || '',
|
|
458
|
+
routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || ''),
|
|
459
|
+
guardOnly: true
|
|
460
|
+
});
|
|
461
|
+
// Security: Enforce relative or same-origin redirects
|
|
462
|
+
if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
|
|
463
|
+
const loc = String(checkResult.result.location || '/');
|
|
464
|
+
if (loc.includes('://') || loc.startsWith('//')) {
|
|
465
|
+
try {
|
|
466
|
+
const parsedLoc = new URL(loc);
|
|
467
|
+
if (parsedLoc.origin !== targetUrl.origin) {
|
|
468
|
+
checkResult.result.location = '/'; // Fallback to root for open redirect attempt
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
checkResult.result.location = '/';
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
res.writeHead(200, {
|
|
477
|
+
'Content-Type': 'application/json',
|
|
478
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
479
|
+
'Pragma': 'no-cache',
|
|
480
|
+
'Expires': '0',
|
|
481
|
+
'Vary': 'Cookie'
|
|
482
|
+
});
|
|
483
|
+
res.end(JSON.stringify({
|
|
484
|
+
result: checkResult?.result || checkResult,
|
|
485
|
+
routeId: resolvedCheck.route.route_id || '',
|
|
486
|
+
to: targetUrl.toString()
|
|
487
|
+
}));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (extname(url.pathname) && extname(url.pathname) !== '.html') {
|
|
332
492
|
const staticPath = resolveWithinDist(distDir, url.pathname);
|
|
333
493
|
if (!staticPath || !(await fileExists(staticPath))) {
|
|
334
494
|
throw new Error('not found');
|
|
@@ -345,7 +505,11 @@ export async function createPreviewServer(options) {
|
|
|
345
505
|
let htmlPath = null;
|
|
346
506
|
|
|
347
507
|
if (resolved.matched && resolved.route) {
|
|
348
|
-
|
|
508
|
+
if (verboseLogging) {
|
|
509
|
+
logger.router(
|
|
510
|
+
`${req.method || 'GET'} ${url.pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`
|
|
511
|
+
);
|
|
512
|
+
}
|
|
349
513
|
const output = resolved.route.output.startsWith('/')
|
|
350
514
|
? resolved.route.output.slice(1)
|
|
351
515
|
: resolved.route.output;
|
|
@@ -358,11 +522,11 @@ export async function createPreviewServer(options) {
|
|
|
358
522
|
throw new Error('not found');
|
|
359
523
|
}
|
|
360
524
|
|
|
361
|
-
let
|
|
525
|
+
let ssrPayload = null;
|
|
362
526
|
if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
|
|
363
|
-
let
|
|
527
|
+
let routeExecution = null;
|
|
364
528
|
try {
|
|
365
|
-
|
|
529
|
+
routeExecution = await executeServerRoute({
|
|
366
530
|
source: resolved.route.server_script,
|
|
367
531
|
sourcePath: resolved.route.server_script_path || '',
|
|
368
532
|
params: resolved.params,
|
|
@@ -371,22 +535,49 @@ export async function createPreviewServer(options) {
|
|
|
371
535
|
requestHeaders: req.headers,
|
|
372
536
|
routePattern: resolved.route.path,
|
|
373
537
|
routeFile: resolved.route.server_script_path || '',
|
|
374
|
-
routeId: routeIdFromSourcePath(resolved.route.server_script_path || '')
|
|
538
|
+
routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
|
|
375
539
|
});
|
|
376
540
|
} catch (error) {
|
|
377
|
-
|
|
541
|
+
ssrPayload = {
|
|
378
542
|
__zenith_error: {
|
|
379
|
-
status: 500,
|
|
380
543
|
code: 'LOAD_FAILED',
|
|
381
544
|
message: error instanceof Error ? error.message : String(error)
|
|
382
545
|
}
|
|
383
546
|
};
|
|
384
547
|
}
|
|
385
|
-
|
|
386
|
-
|
|
548
|
+
|
|
549
|
+
const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
|
|
550
|
+
const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
|
|
551
|
+
if (verboseLogging) {
|
|
552
|
+
logger.router(`${routeId} guard=${trace.guard} load=${trace.load}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const result = routeExecution?.result;
|
|
556
|
+
if (result && result.kind === 'redirect') {
|
|
557
|
+
const status = Number.isInteger(result.status) ? result.status : 302;
|
|
558
|
+
res.writeHead(status, {
|
|
559
|
+
Location: result.location,
|
|
560
|
+
'Cache-Control': 'no-store'
|
|
561
|
+
});
|
|
562
|
+
res.end('');
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (result && result.kind === 'deny') {
|
|
566
|
+
const status = Number.isInteger(result.status) ? result.status : 403;
|
|
567
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
568
|
+
res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
572
|
+
ssrPayload = result.data;
|
|
387
573
|
}
|
|
388
574
|
}
|
|
389
575
|
|
|
576
|
+
let html = await readFile(htmlPath, 'utf8');
|
|
577
|
+
if (ssrPayload) {
|
|
578
|
+
html = injectSsrPayload(html, ssrPayload);
|
|
579
|
+
}
|
|
580
|
+
|
|
390
581
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
391
582
|
res.end(html);
|
|
392
583
|
} catch {
|
|
@@ -396,8 +587,8 @@ export async function createPreviewServer(options) {
|
|
|
396
587
|
});
|
|
397
588
|
|
|
398
589
|
return new Promise((resolveServer) => {
|
|
399
|
-
server.listen(port, () => {
|
|
400
|
-
|
|
590
|
+
server.listen(port, host, () => {
|
|
591
|
+
actualPort = server.address().port;
|
|
401
592
|
resolveServer({
|
|
402
593
|
server,
|
|
403
594
|
port: actualPort,
|
|
@@ -416,6 +607,13 @@ export async function createPreviewServer(options) {
|
|
|
416
607
|
* server_script?: string | null;
|
|
417
608
|
* server_script_path?: string | null;
|
|
418
609
|
* prerender?: boolean;
|
|
610
|
+
* route_id?: string;
|
|
611
|
+
* pattern?: string;
|
|
612
|
+
* params_shape?: Record<string, string>;
|
|
613
|
+
* has_guard?: boolean;
|
|
614
|
+
* has_load?: boolean;
|
|
615
|
+
* guard_module_ref?: string | null;
|
|
616
|
+
* load_module_ref?: string | null;
|
|
419
617
|
* }} PreviewRoute
|
|
420
618
|
*/
|
|
421
619
|
|
|
@@ -446,28 +644,120 @@ export const matchRoute = matchManifestRoute;
|
|
|
446
644
|
|
|
447
645
|
/**
|
|
448
646
|
* @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<
|
|
647
|
+
* @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, load: string } }>}
|
|
450
648
|
*/
|
|
451
|
-
export async function
|
|
649
|
+
export async function executeServerRoute({
|
|
650
|
+
source,
|
|
651
|
+
sourcePath,
|
|
652
|
+
params,
|
|
653
|
+
requestUrl,
|
|
654
|
+
requestMethod,
|
|
655
|
+
requestHeaders,
|
|
656
|
+
routePattern,
|
|
657
|
+
routeFile,
|
|
658
|
+
routeId,
|
|
659
|
+
guardOnly = false
|
|
660
|
+
}) {
|
|
661
|
+
if (!source || !String(source).trim()) {
|
|
662
|
+
return {
|
|
663
|
+
result: { kind: 'data', data: {} },
|
|
664
|
+
trace: { guard: 'none', load: 'none' }
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
452
668
|
const payload = await spawnNodeServerRunner({
|
|
453
|
-
source
|
|
454
|
-
sourcePath
|
|
455
|
-
params
|
|
456
|
-
requestUrl:
|
|
457
|
-
requestMethod:
|
|
458
|
-
requestHeaders: sanitizeRequestHeaders(
|
|
459
|
-
routePattern:
|
|
460
|
-
routeFile:
|
|
461
|
-
routeId:
|
|
669
|
+
source,
|
|
670
|
+
sourcePath,
|
|
671
|
+
params,
|
|
672
|
+
requestUrl: requestUrl || 'http://localhost/',
|
|
673
|
+
requestMethod: requestMethod || 'GET',
|
|
674
|
+
requestHeaders: sanitizeRequestHeaders(requestHeaders || {}),
|
|
675
|
+
routePattern: routePattern || '',
|
|
676
|
+
routeFile: routeFile || sourcePath || '',
|
|
677
|
+
routeId: routeId || routeIdFromSourcePath(sourcePath || ''),
|
|
678
|
+
guardOnly
|
|
462
679
|
});
|
|
463
680
|
|
|
464
681
|
if (payload === null || payload === undefined) {
|
|
465
|
-
return
|
|
682
|
+
return {
|
|
683
|
+
result: { kind: 'data', data: {} },
|
|
684
|
+
trace: { guard: 'none', load: 'none' }
|
|
685
|
+
};
|
|
466
686
|
}
|
|
467
687
|
if (typeof payload !== 'object' || Array.isArray(payload)) {
|
|
468
688
|
throw new Error('[zenith-preview] server script payload must be an object');
|
|
469
689
|
}
|
|
470
|
-
|
|
690
|
+
|
|
691
|
+
const errorEnvelope = payload.__zenith_error;
|
|
692
|
+
if (errorEnvelope && typeof errorEnvelope === 'object') {
|
|
693
|
+
return {
|
|
694
|
+
result: {
|
|
695
|
+
kind: 'deny',
|
|
696
|
+
status: 500,
|
|
697
|
+
message: String(errorEnvelope.message || 'Server route execution failed')
|
|
698
|
+
},
|
|
699
|
+
trace: { guard: 'none', load: 'deny' }
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const result = payload.result;
|
|
704
|
+
const trace = payload.trace;
|
|
705
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && typeof result.kind === 'string') {
|
|
706
|
+
return {
|
|
707
|
+
result,
|
|
708
|
+
trace: trace && typeof trace === 'object'
|
|
709
|
+
? {
|
|
710
|
+
guard: String(trace.guard || 'none'),
|
|
711
|
+
load: String(trace.load || 'none')
|
|
712
|
+
}
|
|
713
|
+
: { guard: 'none', load: 'none' }
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
result: {
|
|
719
|
+
kind: 'data',
|
|
720
|
+
data: payload
|
|
721
|
+
},
|
|
722
|
+
trace: { guard: 'none', load: 'data' }
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* @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
|
|
728
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
729
|
+
*/
|
|
730
|
+
export async function executeServerScript(input) {
|
|
731
|
+
const execution = await executeServerRoute(input);
|
|
732
|
+
const result = execution?.result;
|
|
733
|
+
if (!result || typeof result !== 'object') {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
if (result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
737
|
+
return result.data;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (result.kind === 'redirect') {
|
|
741
|
+
return {
|
|
742
|
+
__zenith_error: {
|
|
743
|
+
status: Number.isInteger(result.status) ? result.status : 302,
|
|
744
|
+
code: 'REDIRECT',
|
|
745
|
+
message: `Redirect to ${String(result.location || '')}`
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (result.kind === 'deny') {
|
|
751
|
+
return {
|
|
752
|
+
__zenith_error: {
|
|
753
|
+
status: Number.isInteger(result.status) ? result.status : 403,
|
|
754
|
+
code: 'ACCESS_DENIED',
|
|
755
|
+
message: String(result.message || 'Access denied')
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return {};
|
|
471
761
|
}
|
|
472
762
|
|
|
473
763
|
/**
|
|
@@ -491,6 +781,7 @@ function spawnNodeServerRunner(input) {
|
|
|
491
781
|
ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
|
|
492
782
|
ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
|
|
493
783
|
ZENITH_SERVER_ROUTE_ID: input.routeId || '',
|
|
784
|
+
ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
|
|
494
785
|
ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
|
|
495
786
|
},
|
|
496
787
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
@@ -609,7 +900,7 @@ export function resolveWithinDist(distDir, requestPath) {
|
|
|
609
900
|
*/
|
|
610
901
|
function sanitizeRequestHeaders(headers) {
|
|
611
902
|
const out = Object.create(null);
|
|
612
|
-
const denyExact = new Set(['
|
|
903
|
+
const denyExact = new Set(['proxy-authorization', 'set-cookie']);
|
|
613
904
|
const denyPrefixes = ['x-forwarded-', 'cf-'];
|
|
614
905
|
for (const [rawKey, rawValue] of Object.entries(headers || {})) {
|
|
615
906
|
const key = String(rawKey || '').toLowerCase();
|