@zenithbuild/cli 0.5.0-beta.2.12 → 0.5.0-beta.2.16
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 +52 -6
- package/dist/dev-server.js +75 -11
- package/dist/preview.js +237 -26
- package/dist/server-contract.js +143 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
The command-line interface for developing and building Zenith applications.
|
|
7
7
|
|
|
8
|
+
## Canonical Docs
|
|
9
|
+
|
|
10
|
+
- CLI contract: `../zenith-docs/documentation/cli-contract.md`
|
|
11
|
+
- Script server/data contract: `../zenith-docs/documentation/contracts/server-data.md`
|
|
12
|
+
|
|
8
13
|
## Overview
|
|
9
14
|
|
|
10
15
|
`@zenithbuild/cli` provides the toolchain needed to manage Zenith projects. While `create-zenith` is for scaffolding, this CLI is for the daily development loop: serving apps, building for production, and managing plugins.
|
package/dist/build.js
CHANGED
|
@@ -68,6 +68,24 @@ const BUNDLER_BIN = resolveBinary([
|
|
|
68
68
|
resolve(CLI_ROOT, '../zenith-bundler/target/release/zenith-bundler')
|
|
69
69
|
]);
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Build a per-build warning emitter that deduplicates repeated compiler lines.
|
|
73
|
+
*
|
|
74
|
+
* @param {(line: string) => void} sink
|
|
75
|
+
* @returns {(line: string) => void}
|
|
76
|
+
*/
|
|
77
|
+
export function createCompilerWarningEmitter(sink = (line) => console.warn(line)) {
|
|
78
|
+
const emitted = new Set();
|
|
79
|
+
return (line) => {
|
|
80
|
+
const text = String(line || '').trim();
|
|
81
|
+
if (!text || emitted.has(text)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
emitted.add(text);
|
|
85
|
+
sink(text);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
/**
|
|
72
90
|
* Run the compiler process and parse its JSON stdout.
|
|
73
91
|
*
|
|
@@ -77,9 +95,12 @@ const BUNDLER_BIN = resolveBinary([
|
|
|
77
95
|
*
|
|
78
96
|
* @param {string} filePath — path for diagnostics (and file reading when no stdinSource)
|
|
79
97
|
* @param {string} [stdinSource] — if provided, piped to compiler via stdin
|
|
98
|
+
* @param {object} compilerRunOptions
|
|
99
|
+
* @param {(warning: string) => void} [compilerRunOptions.onWarning]
|
|
100
|
+
* @param {boolean} [compilerRunOptions.suppressWarnings]
|
|
80
101
|
* @returns {object}
|
|
81
102
|
*/
|
|
82
|
-
function runCompiler(filePath, stdinSource, compilerOpts = {}) {
|
|
103
|
+
function runCompiler(filePath, stdinSource, compilerOpts = {}, compilerRunOptions = {}) {
|
|
83
104
|
const args = stdinSource !== undefined
|
|
84
105
|
? ['--stdin', filePath]
|
|
85
106
|
: [filePath];
|
|
@@ -102,6 +123,20 @@ function runCompiler(filePath, stdinSource, compilerOpts = {}) {
|
|
|
102
123
|
);
|
|
103
124
|
}
|
|
104
125
|
|
|
126
|
+
if (result.stderr && result.stderr.trim().length > 0 && compilerRunOptions.suppressWarnings !== true) {
|
|
127
|
+
const lines = String(result.stderr)
|
|
128
|
+
.split('\n')
|
|
129
|
+
.map((line) => line.trim())
|
|
130
|
+
.filter((line) => line.length > 0);
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
if (typeof compilerRunOptions.onWarning === 'function') {
|
|
133
|
+
compilerRunOptions.onWarning(line);
|
|
134
|
+
} else {
|
|
135
|
+
console.warn(line);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
105
140
|
try {
|
|
106
141
|
return JSON.parse(result.stdout);
|
|
107
142
|
} catch (err) {
|
|
@@ -143,7 +178,7 @@ function buildComponentExpressionRewrite(compPath, componentSource, compIr, comp
|
|
|
143
178
|
|
|
144
179
|
let templateIr;
|
|
145
180
|
try {
|
|
146
|
-
templateIr = runCompiler(compPath, templateOnly, compilerOpts);
|
|
181
|
+
templateIr = runCompiler(compPath, templateOnly, compilerOpts, { suppressWarnings: true });
|
|
147
182
|
} catch {
|
|
148
183
|
return out;
|
|
149
184
|
}
|
|
@@ -868,7 +903,7 @@ function transpileTypeScriptToJs(source, sourceFile) {
|
|
|
868
903
|
fileName: sourceFile,
|
|
869
904
|
compilerOptions: {
|
|
870
905
|
module: ts.ModuleKind.ESNext,
|
|
871
|
-
target: ts.ScriptTarget.
|
|
906
|
+
target: ts.ScriptTarget.ES5,
|
|
872
907
|
importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Preserve,
|
|
873
908
|
verbatimModuleSyntax: true,
|
|
874
909
|
newLine: ts.NewLineKind.LineFeed,
|
|
@@ -1103,7 +1138,7 @@ function injectPropsPrelude(source, attrs) {
|
|
|
1103
1138
|
}
|
|
1104
1139
|
|
|
1105
1140
|
const propsLiteral = renderPropsLiteralFromAttrs(attrs);
|
|
1106
|
-
return `
|
|
1141
|
+
return `var props = ${propsLiteral};\n${source}`;
|
|
1107
1142
|
}
|
|
1108
1143
|
|
|
1109
1144
|
/**
|
|
@@ -1360,6 +1395,7 @@ export async function build(options) {
|
|
|
1360
1395
|
const componentRefFallbackCache = new Map();
|
|
1361
1396
|
/** @type {Map<string, { map: Map<string, string>, ambiguous: Set<string> }>} */
|
|
1362
1397
|
const componentExpressionRewriteCache = new Map();
|
|
1398
|
+
const emitCompilerWarning = createCompilerWarningEmitter((line) => console.warn(line));
|
|
1363
1399
|
|
|
1364
1400
|
const envelopes = [];
|
|
1365
1401
|
for (const entry of manifest) {
|
|
@@ -1375,7 +1411,12 @@ export async function build(options) {
|
|
|
1375
1411
|
const compileSource = extractedServer.source;
|
|
1376
1412
|
|
|
1377
1413
|
// 2b. Compile expanded page source via --stdin
|
|
1378
|
-
const pageIr = runCompiler(
|
|
1414
|
+
const pageIr = runCompiler(
|
|
1415
|
+
sourceFile,
|
|
1416
|
+
compileSource,
|
|
1417
|
+
compilerOpts,
|
|
1418
|
+
{ onWarning: emitCompilerWarning }
|
|
1419
|
+
);
|
|
1379
1420
|
if (extractedServer.serverScript) {
|
|
1380
1421
|
pageIr.server_script = extractedServer.serverScript;
|
|
1381
1422
|
pageIr.prerender = extractedServer.serverScript.prerender === true;
|
|
@@ -1409,7 +1450,12 @@ export async function build(options) {
|
|
|
1409
1450
|
compIr = componentIrCache.get(compPath);
|
|
1410
1451
|
} else {
|
|
1411
1452
|
const componentCompileSource = stripStyleBlocks(componentSource);
|
|
1412
|
-
compIr = runCompiler(
|
|
1453
|
+
compIr = runCompiler(
|
|
1454
|
+
compPath,
|
|
1455
|
+
componentCompileSource,
|
|
1456
|
+
compilerOpts,
|
|
1457
|
+
{ onWarning: emitCompilerWarning }
|
|
1458
|
+
);
|
|
1413
1459
|
componentIrCache.set(compPath, compIr);
|
|
1414
1460
|
}
|
|
1415
1461
|
|
package/dist/dev-server.js
CHANGED
|
@@ -17,7 +17,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
17
17
|
import { join, extname } from 'node:path';
|
|
18
18
|
import { build } from './build.js';
|
|
19
19
|
import {
|
|
20
|
-
|
|
20
|
+
executeServerRoute,
|
|
21
21
|
injectSsrPayload,
|
|
22
22
|
loadRouteManifest,
|
|
23
23
|
resolveWithinDist,
|
|
@@ -93,9 +93,42 @@ export async function createDevServer(options) {
|
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
if (pathname === '/__zenith/route-check') {
|
|
97
|
+
try {
|
|
98
|
+
const targetPath = String(url.searchParams.get('path') || '/');
|
|
99
|
+
const targetUrl = new URL(targetPath, `http://localhost:${port}`);
|
|
100
|
+
const routes = await loadRouteManifest(outDir);
|
|
101
|
+
const resolvedCheck = resolveRequestRoute(targetUrl, routes);
|
|
102
|
+
if (!resolvedCheck.matched || !resolvedCheck.route) {
|
|
103
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
104
|
+
res.end(JSON.stringify({ error: 'route_not_found' }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const checkResult = await executeServerRoute({
|
|
109
|
+
source: resolvedCheck.route.server_script || '',
|
|
110
|
+
sourcePath: resolvedCheck.route.server_script_path || '',
|
|
111
|
+
params: resolvedCheck.params,
|
|
112
|
+
requestUrl: targetUrl.toString(),
|
|
113
|
+
requestMethod: req.method || 'GET',
|
|
114
|
+
requestHeaders: req.headers,
|
|
115
|
+
routePattern: resolvedCheck.route.path,
|
|
116
|
+
routeFile: resolvedCheck.route.server_script_path || '',
|
|
117
|
+
routeId: resolvedCheck.route.route_id || ''
|
|
118
|
+
});
|
|
119
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
120
|
+
res.end(JSON.stringify(checkResult));
|
|
121
|
+
return;
|
|
122
|
+
} catch {
|
|
123
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
124
|
+
res.end(JSON.stringify({ error: 'route_check_failed' }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
try {
|
|
97
130
|
const requestExt = extname(pathname);
|
|
98
|
-
if (requestExt) {
|
|
131
|
+
if (requestExt && requestExt !== '.html') {
|
|
99
132
|
const assetPath = join(outDir, pathname);
|
|
100
133
|
const asset = await readFile(assetPath);
|
|
101
134
|
const mime = MIME_TYPES[requestExt] || 'application/octet-stream';
|
|
@@ -122,11 +155,11 @@ export async function createDevServer(options) {
|
|
|
122
155
|
throw new Error('not found');
|
|
123
156
|
}
|
|
124
157
|
|
|
125
|
-
let
|
|
158
|
+
let ssrPayload = null;
|
|
126
159
|
if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
|
|
127
|
-
let
|
|
160
|
+
let routeExecution = null;
|
|
128
161
|
try {
|
|
129
|
-
|
|
162
|
+
routeExecution = await executeServerRoute({
|
|
130
163
|
source: resolved.route.server_script,
|
|
131
164
|
sourcePath: resolved.route.server_script_path || '',
|
|
132
165
|
params: resolved.params,
|
|
@@ -134,22 +167,53 @@ export async function createDevServer(options) {
|
|
|
134
167
|
requestMethod: req.method || 'GET',
|
|
135
168
|
requestHeaders: req.headers,
|
|
136
169
|
routePattern: resolved.route.path,
|
|
137
|
-
routeFile: resolved.route.server_script_path || ''
|
|
170
|
+
routeFile: resolved.route.server_script_path || '',
|
|
171
|
+
routeId: resolved.route.route_id || ''
|
|
138
172
|
});
|
|
139
173
|
} catch (error) {
|
|
140
|
-
|
|
141
|
-
|
|
174
|
+
routeExecution = {
|
|
175
|
+
result: {
|
|
176
|
+
kind: 'deny',
|
|
142
177
|
status: 500,
|
|
143
|
-
code: 'LOAD_FAILED',
|
|
144
178
|
message: error instanceof Error ? error.message : String(error)
|
|
179
|
+
},
|
|
180
|
+
trace: {
|
|
181
|
+
guard: 'none',
|
|
182
|
+
load: 'deny'
|
|
145
183
|
}
|
|
146
184
|
};
|
|
147
185
|
}
|
|
148
|
-
|
|
149
|
-
|
|
186
|
+
|
|
187
|
+
const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
|
|
188
|
+
const routeId = resolved.route.route_id || '';
|
|
189
|
+
console.log(`[Zenith] guard(${routeId || resolved.route.path}) -> ${trace.guard}`);
|
|
190
|
+
console.log(`[Zenith] load(${routeId || resolved.route.path}) -> ${trace.load}`);
|
|
191
|
+
|
|
192
|
+
const result = routeExecution?.result;
|
|
193
|
+
if (result && result.kind === 'redirect') {
|
|
194
|
+
const status = Number.isInteger(result.status) ? result.status : 302;
|
|
195
|
+
res.writeHead(status, {
|
|
196
|
+
Location: result.location,
|
|
197
|
+
'Cache-Control': 'no-store'
|
|
198
|
+
});
|
|
199
|
+
res.end('');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (result && result.kind === 'deny') {
|
|
203
|
+
const status = Number.isInteger(result.status) ? result.status : 403;
|
|
204
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
205
|
+
res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
209
|
+
ssrPayload = result.data;
|
|
150
210
|
}
|
|
151
211
|
}
|
|
152
212
|
|
|
213
|
+
let content = await readFile(filePath, 'utf8');
|
|
214
|
+
if (ssrPayload) {
|
|
215
|
+
content = injectSsrPayload(content, ssrPayload);
|
|
216
|
+
}
|
|
153
217
|
content = content.replace('</body>', `${HMR_CLIENT_SCRIPT}</body>`);
|
|
154
218
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
155
219
|
res.end(content);
|
package/dist/preview.js
CHANGED
|
@@ -158,6 +158,55 @@ const safeRequestHeaders =
|
|
|
158
158
|
requestHeaders && typeof requestHeaders === 'object'
|
|
159
159
|
? { ...requestHeaders }
|
|
160
160
|
: {};
|
|
161
|
+
function parseCookies(rawCookieHeader) {
|
|
162
|
+
const out = Object.create(null);
|
|
163
|
+
const raw = String(rawCookieHeader || '');
|
|
164
|
+
if (!raw) return out;
|
|
165
|
+
const pairs = raw.split(';');
|
|
166
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
167
|
+
const part = pairs[i];
|
|
168
|
+
const eq = part.indexOf('=');
|
|
169
|
+
if (eq <= 0) continue;
|
|
170
|
+
const key = part.slice(0, eq).trim();
|
|
171
|
+
if (!key) continue;
|
|
172
|
+
const value = part.slice(eq + 1).trim();
|
|
173
|
+
try {
|
|
174
|
+
out[key] = decodeURIComponent(value);
|
|
175
|
+
} catch {
|
|
176
|
+
out[key] = value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
const cookieHeader = typeof safeRequestHeaders.cookie === 'string'
|
|
182
|
+
? safeRequestHeaders.cookie
|
|
183
|
+
: '';
|
|
184
|
+
const requestCookies = parseCookies(cookieHeader);
|
|
185
|
+
|
|
186
|
+
function ctxAllow() {
|
|
187
|
+
return { kind: 'allow' };
|
|
188
|
+
}
|
|
189
|
+
function ctxRedirect(location, status = 302) {
|
|
190
|
+
return {
|
|
191
|
+
kind: 'redirect',
|
|
192
|
+
location: String(location || ''),
|
|
193
|
+
status: Number.isInteger(status) ? status : 302
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function ctxDeny(status = 403, message = undefined) {
|
|
197
|
+
return {
|
|
198
|
+
kind: 'deny',
|
|
199
|
+
status: Number.isInteger(status) ? status : 403,
|
|
200
|
+
message: typeof message === 'string' ? message : undefined
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function ctxData(payload) {
|
|
204
|
+
return {
|
|
205
|
+
kind: 'data',
|
|
206
|
+
data: payload
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
161
210
|
const requestSnapshot = new Request(requestUrl, {
|
|
162
211
|
method: requestMethod,
|
|
163
212
|
headers: new Headers(safeRequestHeaders)
|
|
@@ -168,15 +217,32 @@ const routeMeta = {
|
|
|
168
217
|
pattern: routePattern,
|
|
169
218
|
file: routeFile ? path.relative(process.cwd(), routeFile) : ''
|
|
170
219
|
};
|
|
220
|
+
const routeContext = {
|
|
221
|
+
params: routeParams,
|
|
222
|
+
url: new URL(requestUrl),
|
|
223
|
+
headers: { ...safeRequestHeaders },
|
|
224
|
+
cookies: requestCookies,
|
|
225
|
+
request: requestSnapshot,
|
|
226
|
+
method: requestMethod,
|
|
227
|
+
route: routeMeta,
|
|
228
|
+
env: {},
|
|
229
|
+
auth: {
|
|
230
|
+
async getSession(_ctx) {
|
|
231
|
+
return null;
|
|
232
|
+
},
|
|
233
|
+
async requireSession(_ctx) {
|
|
234
|
+
throw ctxRedirect('/login', 302);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
allow: ctxAllow,
|
|
238
|
+
redirect: ctxRedirect,
|
|
239
|
+
deny: ctxDeny,
|
|
240
|
+
data: ctxData
|
|
241
|
+
};
|
|
171
242
|
|
|
172
243
|
const context = vm.createContext({
|
|
173
244
|
params: routeParams,
|
|
174
|
-
ctx:
|
|
175
|
-
params: routeParams,
|
|
176
|
-
url: new URL(requestUrl),
|
|
177
|
-
request: requestSnapshot,
|
|
178
|
-
route: routeMeta
|
|
179
|
-
},
|
|
245
|
+
ctx: routeContext,
|
|
180
246
|
fetch: globalThis.fetch,
|
|
181
247
|
Headers: globalThis.Headers,
|
|
182
248
|
Request: globalThis.Request,
|
|
@@ -251,11 +317,11 @@ async function linkModule(specifier, parentIdentifier) {
|
|
|
251
317
|
return loadFileModule(resolvedUrl);
|
|
252
318
|
}
|
|
253
319
|
|
|
254
|
-
const allowed = new Set(['data', 'load', 'ssr_data', 'props', 'ssr', 'prerender']);
|
|
320
|
+
const allowed = new Set(['data', 'load', 'guard', 'ssr_data', 'props', 'ssr', 'prerender']);
|
|
255
321
|
const prelude = "const params = globalThis.params;\n" +
|
|
256
322
|
"const ctx = globalThis.ctx;\n" +
|
|
257
|
-
"import {
|
|
258
|
-
"globalThis.
|
|
323
|
+
"import { resolveRouteResult } from 'zenith:server-contract';\n" +
|
|
324
|
+
"globalThis.resolveRouteResult = resolveRouteResult;\n";
|
|
259
325
|
const entryIdentifier = sourcePath
|
|
260
326
|
? pathToFileURL(sourcePath).href
|
|
261
327
|
: 'zenith:server-script';
|
|
@@ -294,13 +360,13 @@ for (const key of namespaceKeys) {
|
|
|
294
360
|
|
|
295
361
|
const exported = entryModule.namespace;
|
|
296
362
|
try {
|
|
297
|
-
const
|
|
363
|
+
const resolved = await context.resolveRouteResult({
|
|
298
364
|
exports: exported,
|
|
299
365
|
ctx: context.ctx,
|
|
300
366
|
filePath: sourcePath || 'server_script'
|
|
301
367
|
});
|
|
302
368
|
|
|
303
|
-
process.stdout.write(JSON.stringify(
|
|
369
|
+
process.stdout.write(JSON.stringify(resolved || null));
|
|
304
370
|
} catch (error) {
|
|
305
371
|
const message = error instanceof Error ? error.message : String(error);
|
|
306
372
|
process.stdout.write(
|
|
@@ -328,7 +394,34 @@ export async function createPreviewServer(options) {
|
|
|
328
394
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
329
395
|
|
|
330
396
|
try {
|
|
331
|
-
if (
|
|
397
|
+
if (url.pathname === '/__zenith/route-check') {
|
|
398
|
+
const targetPath = String(url.searchParams.get('path') || '/');
|
|
399
|
+
const targetUrl = new URL(targetPath, `http://localhost:${port}`);
|
|
400
|
+
const routes = await loadRouteManifest(distDir);
|
|
401
|
+
const resolvedCheck = resolveRequestRoute(targetUrl, routes);
|
|
402
|
+
if (!resolvedCheck.matched || !resolvedCheck.route) {
|
|
403
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
404
|
+
res.end(JSON.stringify({ error: 'route_not_found' }));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const checkResult = await executeServerRoute({
|
|
409
|
+
source: resolvedCheck.route.server_script || '',
|
|
410
|
+
sourcePath: resolvedCheck.route.server_script_path || '',
|
|
411
|
+
params: resolvedCheck.params,
|
|
412
|
+
requestUrl: targetUrl.toString(),
|
|
413
|
+
requestMethod: req.method || 'GET',
|
|
414
|
+
requestHeaders: req.headers,
|
|
415
|
+
routePattern: resolvedCheck.route.path,
|
|
416
|
+
routeFile: resolvedCheck.route.server_script_path || '',
|
|
417
|
+
routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || '')
|
|
418
|
+
});
|
|
419
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
420
|
+
res.end(JSON.stringify(checkResult));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (extname(url.pathname) && extname(url.pathname) !== '.html') {
|
|
332
425
|
const staticPath = resolveWithinDist(distDir, url.pathname);
|
|
333
426
|
if (!staticPath || !(await fileExists(staticPath))) {
|
|
334
427
|
throw new Error('not found');
|
|
@@ -358,11 +451,11 @@ export async function createPreviewServer(options) {
|
|
|
358
451
|
throw new Error('not found');
|
|
359
452
|
}
|
|
360
453
|
|
|
361
|
-
let
|
|
454
|
+
let ssrPayload = null;
|
|
362
455
|
if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
|
|
363
|
-
let
|
|
456
|
+
let routeExecution = null;
|
|
364
457
|
try {
|
|
365
|
-
|
|
458
|
+
routeExecution = await executeServerRoute({
|
|
366
459
|
source: resolved.route.server_script,
|
|
367
460
|
sourcePath: resolved.route.server_script_path || '',
|
|
368
461
|
params: resolved.params,
|
|
@@ -371,22 +464,53 @@ export async function createPreviewServer(options) {
|
|
|
371
464
|
requestHeaders: req.headers,
|
|
372
465
|
routePattern: resolved.route.path,
|
|
373
466
|
routeFile: resolved.route.server_script_path || '',
|
|
374
|
-
routeId: routeIdFromSourcePath(resolved.route.server_script_path || '')
|
|
467
|
+
routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
|
|
375
468
|
});
|
|
376
469
|
} catch (error) {
|
|
377
|
-
|
|
378
|
-
|
|
470
|
+
routeExecution = {
|
|
471
|
+
result: {
|
|
472
|
+
kind: 'deny',
|
|
379
473
|
status: 500,
|
|
380
|
-
code: 'LOAD_FAILED',
|
|
381
474
|
message: error instanceof Error ? error.message : String(error)
|
|
475
|
+
},
|
|
476
|
+
trace: {
|
|
477
|
+
guard: 'none',
|
|
478
|
+
load: 'deny'
|
|
382
479
|
}
|
|
383
480
|
};
|
|
384
481
|
}
|
|
385
|
-
|
|
386
|
-
|
|
482
|
+
|
|
483
|
+
const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
|
|
484
|
+
const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
|
|
485
|
+
console.log(`[Zenith] guard(${routeId}) -> ${trace.guard}`);
|
|
486
|
+
console.log(`[Zenith] load(${routeId}) -> ${trace.load}`);
|
|
487
|
+
|
|
488
|
+
const result = routeExecution?.result;
|
|
489
|
+
if (result && result.kind === 'redirect') {
|
|
490
|
+
const status = Number.isInteger(result.status) ? result.status : 302;
|
|
491
|
+
res.writeHead(status, {
|
|
492
|
+
Location: result.location,
|
|
493
|
+
'Cache-Control': 'no-store'
|
|
494
|
+
});
|
|
495
|
+
res.end('');
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (result && result.kind === 'deny') {
|
|
499
|
+
const status = Number.isInteger(result.status) ? result.status : 403;
|
|
500
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
501
|
+
res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
505
|
+
ssrPayload = result.data;
|
|
387
506
|
}
|
|
388
507
|
}
|
|
389
508
|
|
|
509
|
+
let html = await readFile(htmlPath, 'utf8');
|
|
510
|
+
if (ssrPayload) {
|
|
511
|
+
html = injectSsrPayload(html, ssrPayload);
|
|
512
|
+
}
|
|
513
|
+
|
|
390
514
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
391
515
|
res.end(html);
|
|
392
516
|
} catch {
|
|
@@ -416,6 +540,13 @@ export async function createPreviewServer(options) {
|
|
|
416
540
|
* server_script?: string | null;
|
|
417
541
|
* server_script_path?: string | null;
|
|
418
542
|
* prerender?: boolean;
|
|
543
|
+
* route_id?: string;
|
|
544
|
+
* pattern?: string;
|
|
545
|
+
* params_shape?: Record<string, string>;
|
|
546
|
+
* has_guard?: boolean;
|
|
547
|
+
* has_load?: boolean;
|
|
548
|
+
* guard_module_ref?: string | null;
|
|
549
|
+
* load_module_ref?: string | null;
|
|
419
550
|
* }} PreviewRoute
|
|
420
551
|
*/
|
|
421
552
|
|
|
@@ -446,9 +577,16 @@ export const matchRoute = matchManifestRoute;
|
|
|
446
577
|
|
|
447
578
|
/**
|
|
448
579
|
* @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<
|
|
580
|
+
* @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, load: string } }>}
|
|
450
581
|
*/
|
|
451
|
-
export async function
|
|
582
|
+
export async function executeServerRoute(input) {
|
|
583
|
+
if (!input.source || !String(input.source).trim()) {
|
|
584
|
+
return {
|
|
585
|
+
result: { kind: 'data', data: {} },
|
|
586
|
+
trace: { guard: 'none', load: 'none' }
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
452
590
|
const payload = await spawnNodeServerRunner({
|
|
453
591
|
source: input.source,
|
|
454
592
|
sourcePath: input.sourcePath,
|
|
@@ -462,12 +600,85 @@ export async function executeServerScript(input) {
|
|
|
462
600
|
});
|
|
463
601
|
|
|
464
602
|
if (payload === null || payload === undefined) {
|
|
465
|
-
return
|
|
603
|
+
return {
|
|
604
|
+
result: { kind: 'data', data: {} },
|
|
605
|
+
trace: { guard: 'none', load: 'none' }
|
|
606
|
+
};
|
|
466
607
|
}
|
|
467
608
|
if (typeof payload !== 'object' || Array.isArray(payload)) {
|
|
468
609
|
throw new Error('[zenith-preview] server script payload must be an object');
|
|
469
610
|
}
|
|
470
|
-
|
|
611
|
+
|
|
612
|
+
const errorEnvelope = payload.__zenith_error;
|
|
613
|
+
if (errorEnvelope && typeof errorEnvelope === 'object') {
|
|
614
|
+
return {
|
|
615
|
+
result: {
|
|
616
|
+
kind: 'deny',
|
|
617
|
+
status: 500,
|
|
618
|
+
message: String(errorEnvelope.message || 'Server route execution failed')
|
|
619
|
+
},
|
|
620
|
+
trace: { guard: 'none', load: 'deny' }
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const result = payload.result;
|
|
625
|
+
const trace = payload.trace;
|
|
626
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && typeof result.kind === 'string') {
|
|
627
|
+
return {
|
|
628
|
+
result,
|
|
629
|
+
trace: trace && typeof trace === 'object'
|
|
630
|
+
? {
|
|
631
|
+
guard: String(trace.guard || 'none'),
|
|
632
|
+
load: String(trace.load || 'none')
|
|
633
|
+
}
|
|
634
|
+
: { guard: 'none', load: 'none' }
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
result: {
|
|
640
|
+
kind: 'data',
|
|
641
|
+
data: payload
|
|
642
|
+
},
|
|
643
|
+
trace: { guard: 'none', load: 'data' }
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* @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
|
|
649
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
650
|
+
*/
|
|
651
|
+
export async function executeServerScript(input) {
|
|
652
|
+
const execution = await executeServerRoute(input);
|
|
653
|
+
const result = execution?.result;
|
|
654
|
+
if (!result || typeof result !== 'object') {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
if (result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
658
|
+
return result.data;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (result.kind === 'redirect') {
|
|
662
|
+
return {
|
|
663
|
+
__zenith_error: {
|
|
664
|
+
status: Number.isInteger(result.status) ? result.status : 302,
|
|
665
|
+
code: 'REDIRECT',
|
|
666
|
+
message: `Redirect to ${String(result.location || '')}`
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (result.kind === 'deny') {
|
|
672
|
+
return {
|
|
673
|
+
__zenith_error: {
|
|
674
|
+
status: Number.isInteger(result.status) ? result.status : 403,
|
|
675
|
+
code: 'ACCESS_DENIED',
|
|
676
|
+
message: String(result.message || 'Access denied')
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {};
|
|
471
682
|
}
|
|
472
683
|
|
|
473
684
|
/**
|
|
@@ -609,7 +820,7 @@ export function resolveWithinDist(distDir, requestPath) {
|
|
|
609
820
|
*/
|
|
610
821
|
function sanitizeRequestHeaders(headers) {
|
|
611
822
|
const out = Object.create(null);
|
|
612
|
-
const denyExact = new Set(['
|
|
823
|
+
const denyExact = new Set(['proxy-authorization', 'set-cookie']);
|
|
613
824
|
const denyPrefixes = ['x-forwarded-', 'cf-'];
|
|
614
825
|
for (const [rawKey, rawValue] of Object.entries(headers || {})) {
|
|
615
826
|
const key = String(rawKey || '').toLowerCase();
|
package/dist/server-contract.js
CHANGED
|
@@ -2,9 +2,73 @@
|
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Shared validation and payload resolution logic for <script server> blocks.
|
|
4
4
|
|
|
5
|
-
const NEW_KEYS = new Set(['data', 'load', 'prerender']);
|
|
5
|
+
const NEW_KEYS = new Set(['data', 'load', 'guard', 'prerender']);
|
|
6
6
|
const LEGACY_KEYS = new Set(['ssr_data', 'props', 'ssr', 'prerender']);
|
|
7
|
-
const ALLOWED_KEYS = new Set(['data', 'load', 'prerender', 'ssr_data', 'props', 'ssr']);
|
|
7
|
+
const ALLOWED_KEYS = new Set(['data', 'load', 'guard', 'prerender', 'ssr_data', 'props', 'ssr']);
|
|
8
|
+
|
|
9
|
+
const ROUTE_RESULT_KINDS = new Set(['allow', 'redirect', 'deny', 'data']);
|
|
10
|
+
|
|
11
|
+
export function allow() {
|
|
12
|
+
return { kind: 'allow' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function redirect(location, status = 302) {
|
|
16
|
+
return {
|
|
17
|
+
kind: 'redirect',
|
|
18
|
+
location: String(location || ''),
|
|
19
|
+
status: Number.isInteger(status) ? status : 302
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function deny(status = 403, message = undefined) {
|
|
24
|
+
return {
|
|
25
|
+
kind: 'deny',
|
|
26
|
+
status: Number.isInteger(status) ? status : 403,
|
|
27
|
+
message: typeof message === 'string' ? message : undefined
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function data(payload) {
|
|
32
|
+
return { kind: 'data', data: payload };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRouteResultLike(value) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const kind = value.kind;
|
|
40
|
+
return typeof kind === 'string' && ROUTE_RESULT_KINDS.has(kind);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertValidRouteResultShape(value, where, allowedKinds) {
|
|
44
|
+
if (!isRouteResultLike(value)) {
|
|
45
|
+
throw new Error(`[Zenith] ${where}: invalid route result. Expected object with kind.`);
|
|
46
|
+
}
|
|
47
|
+
const kind = value.kind;
|
|
48
|
+
if (!allowedKinds.has(kind)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[Zenith] ${where}: kind "${kind}" is not allowed here (allowed: ${Array.from(allowedKinds).join(', ')}).`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (kind === 'redirect') {
|
|
55
|
+
if (typeof value.location !== 'string' || value.location.length === 0) {
|
|
56
|
+
throw new Error(`[Zenith] ${where}: redirect requires non-empty string location.`);
|
|
57
|
+
}
|
|
58
|
+
if (value.status !== undefined && (!Number.isInteger(value.status) || value.status < 300 || value.status > 399)) {
|
|
59
|
+
throw new Error(`[Zenith] ${where}: redirect status must be an integer 3xx.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (kind === 'deny') {
|
|
64
|
+
if (!Number.isInteger(value.status) || (value.status !== 401 && value.status !== 403)) {
|
|
65
|
+
throw new Error(`[Zenith] ${where}: deny status must be 401 or 403.`);
|
|
66
|
+
}
|
|
67
|
+
if (value.message !== undefined && typeof value.message !== 'string') {
|
|
68
|
+
throw new Error(`[Zenith] ${where}: deny message must be a string when provided.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
8
72
|
|
|
9
73
|
export function validateServerExports({ exports, filePath }) {
|
|
10
74
|
const exportKeys = Object.keys(exports);
|
|
@@ -16,6 +80,7 @@ export function validateServerExports({ exports, filePath }) {
|
|
|
16
80
|
|
|
17
81
|
const hasData = 'data' in exports;
|
|
18
82
|
const hasLoad = 'load' in exports;
|
|
83
|
+
const hasGuard = 'guard' in exports;
|
|
19
84
|
|
|
20
85
|
const hasNew = hasData || hasLoad;
|
|
21
86
|
const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
|
|
@@ -47,6 +112,20 @@ export function validateServerExports({ exports, filePath }) {
|
|
|
47
112
|
throw new Error(`[Zenith] ${filePath}: "load(ctx)" must not contain rest parameters.`);
|
|
48
113
|
}
|
|
49
114
|
}
|
|
115
|
+
|
|
116
|
+
if (hasGuard && typeof exports.guard !== 'function') {
|
|
117
|
+
throw new Error(`[Zenith] ${filePath}: "guard" must be a function.`);
|
|
118
|
+
}
|
|
119
|
+
if (hasGuard) {
|
|
120
|
+
if (exports.guard.length !== 1) {
|
|
121
|
+
throw new Error(`[Zenith] ${filePath}: "guard(ctx)" must take exactly 1 argument.`);
|
|
122
|
+
}
|
|
123
|
+
const fnStr = exports.guard.toString();
|
|
124
|
+
const paramsMatch = fnStr.match(/^[^{=]+\(([^)]*)\)/);
|
|
125
|
+
if (paramsMatch && paramsMatch[1].includes('...')) {
|
|
126
|
+
throw new Error(`[Zenith] ${filePath}: "guard(ctx)" must not contain rest parameters.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
50
129
|
}
|
|
51
130
|
|
|
52
131
|
export function assertJsonSerializable(value, where = 'payload') {
|
|
@@ -110,37 +189,90 @@ export function assertJsonSerializable(value, where = 'payload') {
|
|
|
110
189
|
walk(value, '$');
|
|
111
190
|
}
|
|
112
191
|
|
|
113
|
-
export async function
|
|
192
|
+
export async function resolveRouteResult({ exports, ctx, filePath }) {
|
|
114
193
|
validateServerExports({ exports, filePath });
|
|
115
194
|
|
|
195
|
+
const trace = {
|
|
196
|
+
guard: 'none',
|
|
197
|
+
load: 'none'
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if ('guard' in exports) {
|
|
201
|
+
const guardRaw = await exports.guard(ctx);
|
|
202
|
+
const guardResult = guardRaw == null ? allow() : guardRaw;
|
|
203
|
+
assertValidRouteResultShape(
|
|
204
|
+
guardResult,
|
|
205
|
+
`${filePath}: guard(ctx) return`,
|
|
206
|
+
new Set(['allow', 'redirect', 'deny'])
|
|
207
|
+
);
|
|
208
|
+
trace.guard = guardResult.kind;
|
|
209
|
+
if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
|
|
210
|
+
return { result: guardResult, trace };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
116
214
|
let payload;
|
|
117
215
|
if ('load' in exports) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
216
|
+
const loadRaw = await exports.load(ctx);
|
|
217
|
+
let loadResult = null;
|
|
218
|
+
if (isRouteResultLike(loadRaw)) {
|
|
219
|
+
loadResult = loadRaw;
|
|
220
|
+
assertValidRouteResultShape(
|
|
221
|
+
loadResult,
|
|
222
|
+
`${filePath}: load(ctx) return`,
|
|
223
|
+
new Set(['data', 'redirect', 'deny'])
|
|
224
|
+
);
|
|
225
|
+
} else {
|
|
226
|
+
assertJsonSerializable(loadRaw, `${filePath}: load(ctx) return`);
|
|
227
|
+
loadResult = data(loadRaw);
|
|
228
|
+
}
|
|
229
|
+
trace.load = loadResult.kind;
|
|
230
|
+
return { result: loadResult, trace };
|
|
121
231
|
}
|
|
122
232
|
if ('data' in exports) {
|
|
123
233
|
payload = exports.data;
|
|
124
234
|
assertJsonSerializable(payload, `${filePath}: data export`);
|
|
125
|
-
|
|
235
|
+
trace.load = 'data';
|
|
236
|
+
return { result: data(payload), trace };
|
|
126
237
|
}
|
|
127
238
|
|
|
128
239
|
// legacy fallback
|
|
129
240
|
if ('ssr_data' in exports) {
|
|
130
241
|
payload = exports.ssr_data;
|
|
131
242
|
assertJsonSerializable(payload, `${filePath}: ssr_data export`);
|
|
132
|
-
|
|
243
|
+
trace.load = 'data';
|
|
244
|
+
return { result: data(payload), trace };
|
|
133
245
|
}
|
|
134
246
|
if ('props' in exports) {
|
|
135
247
|
payload = exports.props;
|
|
136
248
|
assertJsonSerializable(payload, `${filePath}: props export`);
|
|
137
|
-
|
|
249
|
+
trace.load = 'data';
|
|
250
|
+
return { result: data(payload), trace };
|
|
138
251
|
}
|
|
139
252
|
if ('ssr' in exports) {
|
|
140
253
|
payload = exports.ssr;
|
|
141
254
|
assertJsonSerializable(payload, `${filePath}: ssr export`);
|
|
142
|
-
|
|
255
|
+
trace.load = 'data';
|
|
256
|
+
return { result: data(payload), trace };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { result: data({}), trace };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function resolveServerPayload({ exports, ctx, filePath }) {
|
|
263
|
+
const resolved = await resolveRouteResult({ exports, ctx, filePath });
|
|
264
|
+
if (!resolved || !resolved.result || typeof resolved.result !== 'object') {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (resolved.result.kind === 'data') {
|
|
269
|
+
return resolved.result.data;
|
|
270
|
+
}
|
|
271
|
+
if (resolved.result.kind === 'allow') {
|
|
272
|
+
return {};
|
|
143
273
|
}
|
|
144
274
|
|
|
145
|
-
|
|
275
|
+
throw new Error(
|
|
276
|
+
`[Zenith] ${filePath}: resolveServerPayload() expected data but received ${resolved.result.kind}. Use resolveRouteResult() for guard/load flows.`
|
|
277
|
+
);
|
|
146
278
|
}
|
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.16",
|
|
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.16"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@jest/globals": "^30.2.0",
|