@zenithbuild/cli 0.7.4 → 0.7.7
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 -3
- package/dist/adapters/adapter-netlify.d.ts +1 -1
- package/dist/adapters/adapter-netlify.js +48 -14
- package/dist/adapters/adapter-static-export.d.ts +5 -0
- package/dist/adapters/adapter-static-export.js +115 -0
- package/dist/adapters/adapter-types.d.ts +3 -1
- package/dist/adapters/adapter-types.js +5 -2
- package/dist/adapters/adapter-vercel.d.ts +1 -1
- package/dist/adapters/adapter-vercel.js +67 -19
- package/dist/adapters/copy-hosted-page-runtime.d.ts +1 -0
- package/dist/adapters/copy-hosted-page-runtime.js +50 -0
- package/dist/adapters/resolve-adapter.js +4 -0
- package/dist/adapters/route-rules.d.ts +5 -0
- package/dist/adapters/route-rules.js +9 -0
- package/dist/adapters/validate-hosted-resource-routes.d.ts +1 -0
- package/dist/adapters/validate-hosted-resource-routes.js +13 -0
- package/dist/auth/route-auth.d.ts +6 -0
- package/dist/auth/route-auth.js +236 -0
- package/dist/build/compiler-runtime.d.ts +1 -1
- package/dist/build/compiler-runtime.js +8 -2
- package/dist/build/hoisted-code-transforms.d.ts +4 -1
- package/dist/build/hoisted-code-transforms.js +5 -3
- package/dist/build/page-ir-normalization.d.ts +1 -1
- package/dist/build/page-ir-normalization.js +33 -3
- package/dist/build/page-loop-state.js +1 -1
- package/dist/build/page-loop.js +46 -2
- package/dist/build/server-script.d.ts +2 -1
- package/dist/build/server-script.js +7 -3
- package/dist/build-output-manifest.d.ts +3 -2
- package/dist/build-output-manifest.js +3 -0
- package/dist/build.js +29 -17
- package/dist/dev-build-session/helpers.d.ts +29 -0
- package/dist/dev-build-session/helpers.js +223 -0
- package/dist/dev-build-session/session.d.ts +24 -0
- package/dist/dev-build-session/session.js +204 -0
- package/dist/dev-build-session/state.d.ts +37 -0
- package/dist/dev-build-session/state.js +17 -0
- package/dist/dev-build-session.d.ts +1 -24
- package/dist/dev-build-session.js +1 -434
- package/dist/dev-server/css-state.d.ts +7 -0
- package/dist/dev-server/css-state.js +92 -0
- package/dist/dev-server/not-found.d.ts +23 -0
- package/dist/dev-server/not-found.js +129 -0
- package/dist/dev-server/request-handler.d.ts +1 -0
- package/dist/dev-server/request-handler.js +376 -0
- package/dist/dev-server/route-check.d.ts +9 -0
- package/dist/dev-server/route-check.js +100 -0
- package/dist/dev-server/watcher.d.ts +5 -0
- package/dist/dev-server/watcher.js +216 -0
- package/dist/dev-server.js +136 -883
- package/dist/download-result.d.ts +14 -0
- package/dist/download-result.js +148 -0
- package/dist/images/payload.js +4 -0
- package/dist/images/service.d.ts +13 -1
- package/dist/images/service.js +45 -15
- package/dist/manifest.d.ts +15 -1
- package/dist/manifest.js +70 -6
- package/dist/preview/create-preview-server.d.ts +18 -0
- package/dist/preview/create-preview-server.js +71 -0
- package/dist/preview/manifest.d.ts +42 -0
- package/dist/preview/manifest.js +57 -0
- package/dist/preview/paths.d.ts +3 -0
- package/dist/preview/paths.js +38 -0
- package/dist/preview/payload.d.ts +6 -0
- package/dist/preview/payload.js +34 -0
- package/dist/preview/request-handler.d.ts +1 -0
- package/dist/preview/request-handler.js +300 -0
- package/dist/preview/server-runner.d.ts +49 -0
- package/dist/preview/server-runner.js +220 -0
- package/dist/preview/server-script-runner-template.d.ts +1 -0
- package/dist/preview/server-script-runner-template.js +425 -0
- package/dist/preview.d.ts +5 -104
- package/dist/preview.js +7 -993
- package/dist/request-body.d.ts +0 -1
- package/dist/request-body.js +0 -6
- package/dist/resource-manifest.d.ts +16 -0
- package/dist/resource-manifest.js +53 -0
- package/dist/resource-response.d.ts +49 -0
- package/dist/resource-response.js +160 -0
- package/dist/resource-route-module.d.ts +15 -0
- package/dist/resource-route-module.js +129 -0
- package/dist/route-check-support.js +1 -1
- package/dist/server-contract/constants.d.ts +5 -0
- package/dist/server-contract/constants.js +5 -0
- package/dist/server-contract/export-validation.d.ts +5 -0
- package/dist/server-contract/export-validation.js +59 -0
- package/dist/server-contract/json-serializable.d.ts +1 -0
- package/dist/server-contract/json-serializable.js +52 -0
- package/dist/server-contract/resolve.d.ts +15 -0
- package/dist/server-contract/resolve.js +271 -0
- package/dist/server-contract/result-helpers.d.ts +51 -0
- package/dist/server-contract/result-helpers.js +59 -0
- package/dist/server-contract/route-result-validation.d.ts +2 -0
- package/dist/server-contract/route-result-validation.js +73 -0
- package/dist/server-contract/stage.d.ts +6 -0
- package/dist/server-contract/stage.js +22 -0
- package/dist/server-contract.d.ts +6 -54
- package/dist/server-contract.js +9 -301
- package/dist/server-error.d.ts +1 -1
- package/dist/server-error.js +2 -0
- package/dist/server-middleware.d.ts +10 -0
- package/dist/server-middleware.js +30 -0
- package/dist/server-output.d.ts +2 -1
- package/dist/server-output.js +72 -12
- package/dist/server-runtime/node-server.js +59 -7
- package/dist/server-runtime/route-render.d.ts +25 -1
- package/dist/server-runtime/route-render.js +81 -29
- package/dist/server-script-composition.d.ts +4 -2
- package/dist/server-script-composition.js +6 -3
- package/dist/static-export-paths.d.ts +3 -0
- package/dist/static-export-paths.js +160 -0
- package/package.json +3 -3
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { extname, normalize, resolve, sep } from 'node:path';
|
|
3
|
+
export function toStaticFilePath(distDir, pathname) {
|
|
4
|
+
let resolved = pathname;
|
|
5
|
+
if (resolved === '/') {
|
|
6
|
+
resolved = '/index.html';
|
|
7
|
+
}
|
|
8
|
+
else if (!extname(resolved)) {
|
|
9
|
+
resolved += '/index.html';
|
|
10
|
+
}
|
|
11
|
+
return resolveWithinDist(distDir, resolved);
|
|
12
|
+
}
|
|
13
|
+
export function resolveWithinDist(distDir, requestPath) {
|
|
14
|
+
let decoded = requestPath;
|
|
15
|
+
try {
|
|
16
|
+
decoded = decodeURIComponent(requestPath);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const normalized = normalize(decoded).replace(/\\/g, '/');
|
|
22
|
+
const relative = normalized.replace(/^\/+/, '');
|
|
23
|
+
const root = resolve(distDir);
|
|
24
|
+
const candidate = resolve(root, relative);
|
|
25
|
+
if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export async function fileExists(fullPath) {
|
|
31
|
+
try {
|
|
32
|
+
await access(fullPath);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} html
|
|
3
|
+
* @param {Record<string, unknown>} payload
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
export function injectSsrPayload(html, payload) {
|
|
7
|
+
const serialized = serializeInlineScriptJson(payload);
|
|
8
|
+
const scriptTag = `<script id="zenith-ssr-data">window.__zenith_ssr_data = ${serialized};</script>`;
|
|
9
|
+
const existingTagRe = /<script\b[^>]*\bid=(["'])zenith-ssr-data\1[^>]*>[\s\S]*?<\/script>/i;
|
|
10
|
+
if (existingTagRe.test(html)) {
|
|
11
|
+
return html.replace(existingTagRe, scriptTag);
|
|
12
|
+
}
|
|
13
|
+
const headClose = html.match(/<\/head>/i);
|
|
14
|
+
if (headClose) {
|
|
15
|
+
return html.replace(/<\/head>/i, `${scriptTag}</head>`);
|
|
16
|
+
}
|
|
17
|
+
const bodyOpen = html.match(/<body\b[^>]*>/i);
|
|
18
|
+
if (bodyOpen) {
|
|
19
|
+
return html.replace(bodyOpen[0], `${bodyOpen[0]}${scriptTag}`);
|
|
20
|
+
}
|
|
21
|
+
return `${scriptTag}${html}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* @param {Record<string, unknown>} payload
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function serializeInlineScriptJson(payload) {
|
|
28
|
+
return JSON.stringify(payload)
|
|
29
|
+
.replace(/</g, '\\u003C')
|
|
30
|
+
.replace(/>/g, '\\u003E')
|
|
31
|
+
.replace(/\//g, '\\u002F')
|
|
32
|
+
.replace(/\u2028/g, '\\u2028')
|
|
33
|
+
.replace(/\u2029/g, '\\u2029');
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function createPreviewRequestHandler(options: any): (req: any, res: any) => Promise<void>;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, join } from 'node:path';
|
|
3
|
+
import { appLocalRedirectLocation, imageEndpointPath, routeCheckPath, stripBasePath } from '../base-path.js';
|
|
4
|
+
import { materializeImageMarkup } from '../images/materialize.js';
|
|
5
|
+
import { createImageRuntimePayload, injectImageRuntimePayload } from '../images/payload.js';
|
|
6
|
+
import { handleImageRequest } from '../images/service.js';
|
|
7
|
+
import { readRequestBodyBuffer } from '../request-body.js';
|
|
8
|
+
import { buildResourceResponseDescriptor } from '../resource-response.js';
|
|
9
|
+
import { clientFacingRouteMessage, logServerException, sanitizeRouteResult } from '../server-error.js';
|
|
10
|
+
import { resolveRequestRoute } from '../server/resolve-request-route.js';
|
|
11
|
+
import { loadRouteSurfaceState } from './manifest.js';
|
|
12
|
+
import { injectSsrPayload } from './payload.js';
|
|
13
|
+
import { fileExists, resolveWithinDist, toStaticFilePath } from './paths.js';
|
|
14
|
+
import { executeServerRoute, routeIdFromSourcePath } from './server-runner.js';
|
|
15
|
+
const MIME_TYPES = {
|
|
16
|
+
'.html': 'text/html',
|
|
17
|
+
'.js': 'application/javascript',
|
|
18
|
+
'.css': 'text/css',
|
|
19
|
+
'.json': 'application/json',
|
|
20
|
+
'.png': 'image/png',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.jpg': 'image/jpeg',
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.webp': 'image/webp',
|
|
25
|
+
'.avif': 'image/avif',
|
|
26
|
+
'.gif': 'image/gif'
|
|
27
|
+
};
|
|
28
|
+
const IMAGE_RUNTIME_TAG_RE = /<script\b[^>]*\bid=(["'])zenith-image-runtime\1[^>]*>[\s\S]*?<\/script>/i;
|
|
29
|
+
function appendSetCookieHeaders(headers, setCookies = []) {
|
|
30
|
+
if (Array.isArray(setCookies) && setCookies.length > 0) {
|
|
31
|
+
headers['Set-Cookie'] = setCookies.slice();
|
|
32
|
+
}
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
35
|
+
export function createPreviewRequestHandler(options) {
|
|
36
|
+
const { distDir, projectRoot, config, logger, verboseLogging, configuredBasePath, routeCheckEnabled, isStaticExportTarget, serverOrigin } = options;
|
|
37
|
+
async function loadImageManifest() {
|
|
38
|
+
try {
|
|
39
|
+
const manifestRaw = await readFile(join(distDir, '_zenith', 'image', 'manifest.json'), 'utf8');
|
|
40
|
+
const parsed = JSON.parse(manifestRaw);
|
|
41
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return async function previewRequestHandler(req, res) {
|
|
48
|
+
const url = new URL(req.url, serverOrigin());
|
|
49
|
+
const { basePath, pageRoutes, resourceRoutes } = await loadRouteSurfaceState(distDir, configuredBasePath);
|
|
50
|
+
const canonicalPath = stripBasePath(url.pathname, basePath);
|
|
51
|
+
try {
|
|
52
|
+
if (url.pathname === routeCheckPath(basePath)) {
|
|
53
|
+
if (!routeCheckEnabled) {
|
|
54
|
+
res.writeHead(501, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
55
|
+
res.end(JSON.stringify({ error: 'route_check_unsupported' }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Security: Require explicitly designated header to prevent public oracle probing
|
|
59
|
+
if (req.headers['x-zenith-route-check'] !== '1') {
|
|
60
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
61
|
+
res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const targetPath = String(url.searchParams.get('path') || '/');
|
|
65
|
+
// Security: Prevent protocol/domain injection in path
|
|
66
|
+
if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
|
|
67
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
68
|
+
res.end(JSON.stringify({ error: 'invalid_path_format' }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const targetUrl = new URL(targetPath, url.origin);
|
|
72
|
+
if (targetUrl.origin !== url.origin) {
|
|
73
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
74
|
+
res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const canonicalTargetPath = stripBasePath(targetUrl.pathname, basePath);
|
|
78
|
+
if (canonicalTargetPath === null) {
|
|
79
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
80
|
+
res.end(JSON.stringify({ error: 'route_not_found' }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const canonicalTargetUrl = new URL(targetUrl.toString());
|
|
84
|
+
canonicalTargetUrl.pathname = canonicalTargetPath;
|
|
85
|
+
const resolvedCheck = resolveRequestRoute(canonicalTargetUrl, pageRoutes);
|
|
86
|
+
if (!resolvedCheck.matched || !resolvedCheck.route) {
|
|
87
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
88
|
+
res.end(JSON.stringify({ error: 'route_not_found' }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const checkResult = await executeServerRoute({
|
|
92
|
+
source: resolvedCheck.route.server_script || '',
|
|
93
|
+
sourcePath: resolvedCheck.route.server_script_path || '',
|
|
94
|
+
params: resolvedCheck.params,
|
|
95
|
+
requestUrl: targetUrl.toString(),
|
|
96
|
+
requestMethod: req.method || 'GET',
|
|
97
|
+
requestHeaders: req.headers,
|
|
98
|
+
routePattern: resolvedCheck.route.path,
|
|
99
|
+
routeFile: resolvedCheck.route.server_script_path || '',
|
|
100
|
+
routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || ''),
|
|
101
|
+
guardOnly: true
|
|
102
|
+
});
|
|
103
|
+
// Security: Enforce relative or same-origin redirects
|
|
104
|
+
if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
|
|
105
|
+
const loc = appLocalRedirectLocation(checkResult.result.location || '/', basePath);
|
|
106
|
+
checkResult.result.location = loc;
|
|
107
|
+
if (loc.includes('://') || loc.startsWith('//')) {
|
|
108
|
+
try {
|
|
109
|
+
const parsedLoc = new URL(loc);
|
|
110
|
+
if (parsedLoc.origin !== targetUrl.origin) {
|
|
111
|
+
checkResult.result.location = appLocalRedirectLocation('/', basePath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
checkResult.result.location = appLocalRedirectLocation('/', basePath);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
res.writeHead(200, {
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
122
|
+
'Pragma': 'no-cache',
|
|
123
|
+
'Expires': '0',
|
|
124
|
+
'Vary': 'Cookie'
|
|
125
|
+
});
|
|
126
|
+
res.end(JSON.stringify({
|
|
127
|
+
result: sanitizeRouteResult(checkResult?.result || checkResult),
|
|
128
|
+
routeId: resolvedCheck.route.route_id || '',
|
|
129
|
+
to: targetUrl.toString()
|
|
130
|
+
}));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (url.pathname === imageEndpointPath(basePath)) {
|
|
134
|
+
if (isStaticExportTarget) {
|
|
135
|
+
throw new Error('not found');
|
|
136
|
+
}
|
|
137
|
+
await handleImageRequest(req, res, {
|
|
138
|
+
requestUrl: url,
|
|
139
|
+
projectRoot,
|
|
140
|
+
config: config.images
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (canonicalPath === null) {
|
|
145
|
+
throw new Error('not found');
|
|
146
|
+
}
|
|
147
|
+
if (extname(canonicalPath) && extname(canonicalPath) !== '.html') {
|
|
148
|
+
const staticPath = isStaticExportTarget
|
|
149
|
+
? resolveWithinDist(distDir, url.pathname)
|
|
150
|
+
: resolveWithinDist(distDir, canonicalPath);
|
|
151
|
+
if (!staticPath || !(await fileExists(staticPath))) {
|
|
152
|
+
throw new Error('not found');
|
|
153
|
+
}
|
|
154
|
+
const content = await readFile(staticPath);
|
|
155
|
+
const mime = MIME_TYPES[extname(staticPath)] || 'application/octet-stream';
|
|
156
|
+
res.writeHead(200, { 'Content-Type': mime });
|
|
157
|
+
res.end(content);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (isStaticExportTarget) {
|
|
161
|
+
const directHtmlPath = toStaticFilePath(distDir, url.pathname);
|
|
162
|
+
if (!directHtmlPath || !(await fileExists(directHtmlPath))) {
|
|
163
|
+
throw new Error('not found');
|
|
164
|
+
}
|
|
165
|
+
const html = await readFile(directHtmlPath, 'utf8');
|
|
166
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
167
|
+
res.end(html);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const canonicalUrl = new URL(url.toString());
|
|
171
|
+
canonicalUrl.pathname = canonicalPath;
|
|
172
|
+
const resolvedResource = resolveRequestRoute(canonicalUrl, resourceRoutes);
|
|
173
|
+
if (resolvedResource.matched && resolvedResource.route) {
|
|
174
|
+
const requestMethod = req.method || 'GET';
|
|
175
|
+
const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
|
|
176
|
+
? null
|
|
177
|
+
: await readRequestBodyBuffer(req);
|
|
178
|
+
const execution = await executeServerRoute({
|
|
179
|
+
source: resolvedResource.route.server_script || '',
|
|
180
|
+
sourcePath: resolvedResource.route.server_script_path || '',
|
|
181
|
+
params: resolvedResource.params,
|
|
182
|
+
requestUrl: url.toString(),
|
|
183
|
+
requestMethod,
|
|
184
|
+
requestHeaders: req.headers,
|
|
185
|
+
requestBodyBuffer,
|
|
186
|
+
routePattern: resolvedResource.route.path,
|
|
187
|
+
routeFile: resolvedResource.route.server_script_path || '',
|
|
188
|
+
routeId: resolvedResource.route.route_id || routeIdFromSourcePath(resolvedResource.route.server_script_path || ''),
|
|
189
|
+
routeKind: 'resource'
|
|
190
|
+
});
|
|
191
|
+
const descriptor = buildResourceResponseDescriptor(execution?.result, basePath, Array.isArray(execution?.setCookies) ? execution.setCookies : []);
|
|
192
|
+
res.writeHead(descriptor.status, appendSetCookieHeaders(descriptor.headers, descriptor.setCookies));
|
|
193
|
+
if ((req.method || 'GET').toUpperCase() === 'HEAD') {
|
|
194
|
+
res.end();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
res.end(descriptor.body);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const resolved = resolveRequestRoute(canonicalUrl, pageRoutes);
|
|
201
|
+
let htmlPath = null;
|
|
202
|
+
if (resolved.matched && resolved.route) {
|
|
203
|
+
if (verboseLogging) {
|
|
204
|
+
logger.router(`${req.method || 'GET'} ${url.pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`);
|
|
205
|
+
}
|
|
206
|
+
const output = resolved.route.output.startsWith('/')
|
|
207
|
+
? resolved.route.output.slice(1)
|
|
208
|
+
: resolved.route.output;
|
|
209
|
+
htmlPath = resolveWithinDist(distDir, output);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
htmlPath = toStaticFilePath(distDir, url.pathname);
|
|
213
|
+
}
|
|
214
|
+
if (!htmlPath || !(await fileExists(htmlPath))) {
|
|
215
|
+
throw new Error('not found');
|
|
216
|
+
}
|
|
217
|
+
let ssrPayload = null;
|
|
218
|
+
let routeExecution = null;
|
|
219
|
+
if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
|
|
220
|
+
try {
|
|
221
|
+
const requestMethod = req.method || 'GET';
|
|
222
|
+
const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
|
|
223
|
+
? null
|
|
224
|
+
: await readRequestBodyBuffer(req);
|
|
225
|
+
routeExecution = await executeServerRoute({
|
|
226
|
+
source: resolved.route.server_script,
|
|
227
|
+
sourcePath: resolved.route.server_script_path || '',
|
|
228
|
+
params: resolved.params,
|
|
229
|
+
requestUrl: url.toString(),
|
|
230
|
+
requestMethod,
|
|
231
|
+
requestHeaders: req.headers,
|
|
232
|
+
requestBodyBuffer,
|
|
233
|
+
routePattern: resolved.route.path,
|
|
234
|
+
routeFile: resolved.route.server_script_path || '',
|
|
235
|
+
routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
logServerException('preview server route execution failed', error);
|
|
240
|
+
ssrPayload = {
|
|
241
|
+
__zenith_error: {
|
|
242
|
+
status: 500,
|
|
243
|
+
code: 'LOAD_FAILED',
|
|
244
|
+
message: error instanceof Error ? error.message : String(error || '')
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const trace = routeExecution?.trace || { guard: 'none', action: 'none', load: 'none' };
|
|
249
|
+
const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
|
|
250
|
+
const setCookies = Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : [];
|
|
251
|
+
if (verboseLogging) {
|
|
252
|
+
logger.router(`${routeId} guard=${trace.guard} action=${trace.action} load=${trace.load}`);
|
|
253
|
+
}
|
|
254
|
+
const result = routeExecution?.result;
|
|
255
|
+
if (result && result.kind === 'redirect') {
|
|
256
|
+
const status = Number.isInteger(result.status) ? result.status : 302;
|
|
257
|
+
res.writeHead(status, appendSetCookieHeaders({
|
|
258
|
+
Location: appLocalRedirectLocation(result.location, basePath),
|
|
259
|
+
'Cache-Control': 'no-store'
|
|
260
|
+
}, setCookies));
|
|
261
|
+
res.end('');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (result && result.kind === 'deny') {
|
|
265
|
+
const status = Number.isInteger(result.status) ? result.status : 403;
|
|
266
|
+
res.writeHead(status, appendSetCookieHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }, setCookies));
|
|
267
|
+
res.end(clientFacingRouteMessage(status, result.message));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
271
|
+
ssrPayload = result.data;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
let html = await readFile(htmlPath, 'utf8');
|
|
275
|
+
if (resolved.matched) {
|
|
276
|
+
html = await materializeImageMarkup({
|
|
277
|
+
html,
|
|
278
|
+
payload: createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint', basePath),
|
|
279
|
+
imageMaterialization: Array.isArray(resolved.route?.image_materialization)
|
|
280
|
+
? resolved.route.image_materialization
|
|
281
|
+
: []
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (ssrPayload) {
|
|
285
|
+
html = injectSsrPayload(html, ssrPayload);
|
|
286
|
+
}
|
|
287
|
+
if (!IMAGE_RUNTIME_TAG_RE.test(html)) {
|
|
288
|
+
html = injectImageRuntimePayload(html, createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint', basePath));
|
|
289
|
+
}
|
|
290
|
+
res.writeHead(Number.isInteger(routeExecution?.status) ? routeExecution.status : 200, appendSetCookieHeaders({
|
|
291
|
+
'Content-Type': 'text/html'
|
|
292
|
+
}, Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : []));
|
|
293
|
+
res.end(html);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
297
|
+
res.end('404 Not Found');
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl?: string, requestMethod?: string, requestHeaders?: Record<string, string | string[] | undefined>, requestBodyBuffer?: Buffer | null, routePattern?: string, routeFile?: string, routeId?: string, routeKind?: 'page' | 'resource' }} input
|
|
3
|
+
* @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, action: string, load: string }, status?: number, setCookies?: string[] }>}
|
|
4
|
+
*/
|
|
5
|
+
export function executeServerRoute({ source, sourcePath, params, requestUrl, requestMethod, requestHeaders, requestBodyBuffer, routePattern, routeFile, routeId, routeKind, guardOnly }: {
|
|
6
|
+
source: string;
|
|
7
|
+
sourcePath: string;
|
|
8
|
+
params: Record<string, string>;
|
|
9
|
+
requestUrl?: string;
|
|
10
|
+
requestMethod?: string;
|
|
11
|
+
requestHeaders?: Record<string, string | string[] | undefined>;
|
|
12
|
+
requestBodyBuffer?: Buffer | null;
|
|
13
|
+
routePattern?: string;
|
|
14
|
+
routeFile?: string;
|
|
15
|
+
routeId?: string;
|
|
16
|
+
routeKind?: "page" | "resource";
|
|
17
|
+
}): Promise<{
|
|
18
|
+
result: {
|
|
19
|
+
kind: string;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
trace: {
|
|
23
|
+
guard: string;
|
|
24
|
+
action: string;
|
|
25
|
+
load: string;
|
|
26
|
+
};
|
|
27
|
+
status?: number;
|
|
28
|
+
setCookies?: string[];
|
|
29
|
+
}>;
|
|
30
|
+
/**
|
|
31
|
+
* @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
|
|
32
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
33
|
+
*/
|
|
34
|
+
export function executeServerScript(input: {
|
|
35
|
+
source: string;
|
|
36
|
+
sourcePath: string;
|
|
37
|
+
params: Record<string, string>;
|
|
38
|
+
requestUrl?: string;
|
|
39
|
+
requestMethod?: string;
|
|
40
|
+
requestHeaders?: Record<string, string | string[] | undefined>;
|
|
41
|
+
routePattern?: string;
|
|
42
|
+
routeFile?: string;
|
|
43
|
+
routeId?: string;
|
|
44
|
+
}): Promise<Record<string, unknown> | null>;
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} sourcePath
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function routeIdFromSourcePath(sourcePath: string): string;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { clientFacingRouteMessage, defaultRouteDenyMessage } from '../server-error.js';
|
|
5
|
+
import { SERVER_SCRIPT_RUNNER } from './server-script-runner-template.js';
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
/**
|
|
8
|
+
* @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl?: string, requestMethod?: string, requestHeaders?: Record<string, string | string[] | undefined>, requestBodyBuffer?: Buffer | null, routePattern?: string, routeFile?: string, routeId?: string, routeKind?: 'page' | 'resource' }} input
|
|
9
|
+
* @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, action: string, load: string }, status?: number, setCookies?: string[] }>}
|
|
10
|
+
*/
|
|
11
|
+
export async function executeServerRoute({ source, sourcePath, params, requestUrl, requestMethod, requestHeaders, requestBodyBuffer, routePattern, routeFile, routeId, routeKind = 'page', guardOnly = false }) {
|
|
12
|
+
if (!source || !String(source).trim()) {
|
|
13
|
+
return {
|
|
14
|
+
result: { kind: 'data', data: {} },
|
|
15
|
+
trace: { guard: 'none', action: 'none', load: 'none' }
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const payload = await spawnNodeServerRunner({
|
|
19
|
+
source,
|
|
20
|
+
sourcePath,
|
|
21
|
+
params,
|
|
22
|
+
requestUrl: requestUrl || 'http://localhost/',
|
|
23
|
+
requestMethod: requestMethod || 'GET',
|
|
24
|
+
requestHeaders: sanitizeRequestHeaders(requestHeaders || {}),
|
|
25
|
+
requestBodyBuffer: Buffer.isBuffer(requestBodyBuffer) ? requestBodyBuffer : null,
|
|
26
|
+
routePattern: routePattern || '',
|
|
27
|
+
routeFile: routeFile || sourcePath || '',
|
|
28
|
+
routeId: routeId || routeIdFromSourcePath(sourcePath || ''),
|
|
29
|
+
routeKind,
|
|
30
|
+
guardOnly
|
|
31
|
+
});
|
|
32
|
+
if (payload === null || payload === undefined) {
|
|
33
|
+
return {
|
|
34
|
+
result: { kind: 'data', data: {} },
|
|
35
|
+
trace: { guard: 'none', action: 'none', load: 'none' }
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (typeof payload !== 'object' || Array.isArray(payload)) {
|
|
39
|
+
throw new Error('[zenith-preview] server script payload must be an object');
|
|
40
|
+
}
|
|
41
|
+
const errorEnvelope = payload.__zenith_error;
|
|
42
|
+
if (errorEnvelope && typeof errorEnvelope === 'object') {
|
|
43
|
+
return {
|
|
44
|
+
result: {
|
|
45
|
+
kind: 'deny',
|
|
46
|
+
status: 500,
|
|
47
|
+
message: defaultRouteDenyMessage(500)
|
|
48
|
+
},
|
|
49
|
+
trace: { guard: 'none', action: 'none', load: 'deny' }
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const result = payload.result;
|
|
53
|
+
const trace = payload.trace;
|
|
54
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && typeof result.kind === 'string') {
|
|
55
|
+
return {
|
|
56
|
+
result,
|
|
57
|
+
trace: trace && typeof trace === 'object'
|
|
58
|
+
? {
|
|
59
|
+
guard: String(trace.guard || 'none'),
|
|
60
|
+
action: String(trace.action || 'none'),
|
|
61
|
+
load: String(trace.load || 'none')
|
|
62
|
+
}
|
|
63
|
+
: { guard: 'none', action: 'none', load: 'none' },
|
|
64
|
+
status: Number.isInteger(payload.status) ? payload.status : undefined,
|
|
65
|
+
setCookies: Array.isArray(payload.setCookies)
|
|
66
|
+
? payload.setCookies.filter((value) => typeof value === 'string' && value.length > 0)
|
|
67
|
+
: []
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
result: {
|
|
72
|
+
kind: 'data',
|
|
73
|
+
data: payload
|
|
74
|
+
},
|
|
75
|
+
trace: { guard: 'none', action: 'none', load: 'data' }
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* @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
|
|
80
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
81
|
+
*/
|
|
82
|
+
export async function executeServerScript(input) {
|
|
83
|
+
const execution = await executeServerRoute(input);
|
|
84
|
+
const result = execution?.result;
|
|
85
|
+
if (!result || typeof result !== 'object') {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
89
|
+
return result.data;
|
|
90
|
+
}
|
|
91
|
+
if (result.kind === 'redirect') {
|
|
92
|
+
return {
|
|
93
|
+
__zenith_error: {
|
|
94
|
+
status: Number.isInteger(result.status) ? result.status : 302,
|
|
95
|
+
code: 'REDIRECT',
|
|
96
|
+
message: `Redirect to ${String(result.location || '')}`
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (result.kind === 'deny') {
|
|
101
|
+
const status = Number.isInteger(result.status) ? result.status : 403;
|
|
102
|
+
return {
|
|
103
|
+
__zenith_error: {
|
|
104
|
+
status,
|
|
105
|
+
code: status >= 500 ? 'LOAD_FAILED' : (status === 404 ? 'NOT_FOUND' : 'ACCESS_DENIED'),
|
|
106
|
+
message: clientFacingRouteMessage(status, result.message)
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl: string, requestMethod: string, requestHeaders: Record<string, string>, requestBodyBuffer?: Buffer | null, routePattern: string, routeFile: string, routeId: string, routeKind?: 'page' | 'resource' }} input
|
|
114
|
+
* @returns {Promise<unknown>}
|
|
115
|
+
*/
|
|
116
|
+
function spawnNodeServerRunner(input) {
|
|
117
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
118
|
+
const child = spawn(process.execPath, ['--experimental-vm-modules', '--input-type=module', '-e', SERVER_SCRIPT_RUNNER], {
|
|
119
|
+
env: {
|
|
120
|
+
...process.env,
|
|
121
|
+
ZENITH_SERVER_SOURCE: input.source,
|
|
122
|
+
ZENITH_SERVER_SOURCE_PATH: input.sourcePath || '',
|
|
123
|
+
ZENITH_SERVER_PARAMS: JSON.stringify(input.params || {}),
|
|
124
|
+
ZENITH_SERVER_REQUEST_URL: input.requestUrl || 'http://localhost/',
|
|
125
|
+
ZENITH_SERVER_REQUEST_METHOD: input.requestMethod || 'GET',
|
|
126
|
+
ZENITH_SERVER_REQUEST_HEADERS: JSON.stringify(input.requestHeaders || {}),
|
|
127
|
+
ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
|
|
128
|
+
ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
|
|
129
|
+
ZENITH_SERVER_ROUTE_ID: input.routeId || '',
|
|
130
|
+
ZENITH_SERVER_ROUTE_KIND: input.routeKind || 'page',
|
|
131
|
+
ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
|
|
132
|
+
ZENITH_SERVER_CONTRACT_PATH: join(__dirname, '..', 'server-contract.js'),
|
|
133
|
+
ZENITH_SERVER_ROUTE_AUTH_PATH: join(__dirname, '..', 'auth', 'route-auth.js')
|
|
134
|
+
},
|
|
135
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
136
|
+
});
|
|
137
|
+
const runnerRequestBody = Buffer.isBuffer(input.requestBodyBuffer) ? input.requestBodyBuffer : null;
|
|
138
|
+
child.stdin.on('error', () => {
|
|
139
|
+
// ignore broken pipes when the runner exits before consuming stdin
|
|
140
|
+
});
|
|
141
|
+
child.stdin.end(runnerRequestBody && runnerRequestBody.length > 0 ? runnerRequestBody : undefined);
|
|
142
|
+
let stdout = '';
|
|
143
|
+
let stderr = '';
|
|
144
|
+
child.stdout.on('data', (chunk) => {
|
|
145
|
+
stdout += String(chunk);
|
|
146
|
+
});
|
|
147
|
+
child.stderr.on('data', (chunk) => {
|
|
148
|
+
stderr += String(chunk);
|
|
149
|
+
});
|
|
150
|
+
child.on('error', (error) => {
|
|
151
|
+
rejectPromise(error);
|
|
152
|
+
});
|
|
153
|
+
child.on('close', (code) => {
|
|
154
|
+
if (code !== 0) {
|
|
155
|
+
rejectPromise(new Error(`[zenith-preview] server script execution failed (${code}): ${stderr.trim() || stdout.trim()}`));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const stderrOutput = stderr.trim();
|
|
159
|
+
const internalErrorIndex = stderrOutput.indexOf('[Zenith:Server]');
|
|
160
|
+
if (internalErrorIndex >= 0) {
|
|
161
|
+
console.error(stderrOutput.slice(internalErrorIndex).trim());
|
|
162
|
+
}
|
|
163
|
+
const raw = stdout.trim();
|
|
164
|
+
if (!raw || raw === 'null') {
|
|
165
|
+
resolvePromise(null);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
resolvePromise(JSON.parse(raw));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
rejectPromise(new Error(`[zenith-preview] invalid server payload JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* @param {Record<string, string | string[] | undefined>} headers
|
|
179
|
+
* @returns {Record<string, string>}
|
|
180
|
+
*/
|
|
181
|
+
function sanitizeRequestHeaders(headers) {
|
|
182
|
+
const out = Object.create(null);
|
|
183
|
+
const denyExact = new Set(['proxy-authorization', 'set-cookie']);
|
|
184
|
+
const denyPrefixes = ['x-forwarded-', 'cf-'];
|
|
185
|
+
for (const [rawKey, rawValue] of Object.entries(headers || {})) {
|
|
186
|
+
const key = String(rawKey || '').toLowerCase();
|
|
187
|
+
if (!key)
|
|
188
|
+
continue;
|
|
189
|
+
if (denyExact.has(key))
|
|
190
|
+
continue;
|
|
191
|
+
if (denyPrefixes.some((prefix) => key.startsWith(prefix)))
|
|
192
|
+
continue;
|
|
193
|
+
let value = '';
|
|
194
|
+
if (Array.isArray(rawValue)) {
|
|
195
|
+
value = rawValue.filter((entry) => entry !== undefined).map(String).join(', ');
|
|
196
|
+
}
|
|
197
|
+
else if (rawValue !== undefined) {
|
|
198
|
+
value = String(rawValue);
|
|
199
|
+
}
|
|
200
|
+
out[key] = value;
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* @param {string} sourcePath
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
export function routeIdFromSourcePath(sourcePath) {
|
|
209
|
+
const normalized = String(sourcePath || '').replaceAll('\\', '/');
|
|
210
|
+
const marker = '/pages/';
|
|
211
|
+
const markerIndex = normalized.lastIndexOf(marker);
|
|
212
|
+
let routeId = markerIndex >= 0
|
|
213
|
+
? normalized.slice(markerIndex + marker.length)
|
|
214
|
+
: normalized.split('/').pop() || normalized;
|
|
215
|
+
routeId = routeId.replace(/\.zen$/i, '');
|
|
216
|
+
if (routeId.endsWith('/index')) {
|
|
217
|
+
routeId = routeId.slice(0, -('/index'.length));
|
|
218
|
+
}
|
|
219
|
+
return routeId || 'index';
|
|
220
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SERVER_SCRIPT_RUNNER: string;
|