@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.
Files changed (112) hide show
  1. package/README.md +5 -3
  2. package/dist/adapters/adapter-netlify.d.ts +1 -1
  3. package/dist/adapters/adapter-netlify.js +48 -14
  4. package/dist/adapters/adapter-static-export.d.ts +5 -0
  5. package/dist/adapters/adapter-static-export.js +115 -0
  6. package/dist/adapters/adapter-types.d.ts +3 -1
  7. package/dist/adapters/adapter-types.js +5 -2
  8. package/dist/adapters/adapter-vercel.d.ts +1 -1
  9. package/dist/adapters/adapter-vercel.js +67 -19
  10. package/dist/adapters/copy-hosted-page-runtime.d.ts +1 -0
  11. package/dist/adapters/copy-hosted-page-runtime.js +50 -0
  12. package/dist/adapters/resolve-adapter.js +4 -0
  13. package/dist/adapters/route-rules.d.ts +5 -0
  14. package/dist/adapters/route-rules.js +9 -0
  15. package/dist/adapters/validate-hosted-resource-routes.d.ts +1 -0
  16. package/dist/adapters/validate-hosted-resource-routes.js +13 -0
  17. package/dist/auth/route-auth.d.ts +6 -0
  18. package/dist/auth/route-auth.js +236 -0
  19. package/dist/build/compiler-runtime.d.ts +1 -1
  20. package/dist/build/compiler-runtime.js +8 -2
  21. package/dist/build/hoisted-code-transforms.d.ts +4 -1
  22. package/dist/build/hoisted-code-transforms.js +5 -3
  23. package/dist/build/page-ir-normalization.d.ts +1 -1
  24. package/dist/build/page-ir-normalization.js +33 -3
  25. package/dist/build/page-loop-state.js +1 -1
  26. package/dist/build/page-loop.js +46 -2
  27. package/dist/build/server-script.d.ts +2 -1
  28. package/dist/build/server-script.js +7 -3
  29. package/dist/build-output-manifest.d.ts +3 -2
  30. package/dist/build-output-manifest.js +3 -0
  31. package/dist/build.js +29 -17
  32. package/dist/dev-build-session/helpers.d.ts +29 -0
  33. package/dist/dev-build-session/helpers.js +223 -0
  34. package/dist/dev-build-session/session.d.ts +24 -0
  35. package/dist/dev-build-session/session.js +204 -0
  36. package/dist/dev-build-session/state.d.ts +37 -0
  37. package/dist/dev-build-session/state.js +17 -0
  38. package/dist/dev-build-session.d.ts +1 -24
  39. package/dist/dev-build-session.js +1 -434
  40. package/dist/dev-server/css-state.d.ts +7 -0
  41. package/dist/dev-server/css-state.js +92 -0
  42. package/dist/dev-server/not-found.d.ts +23 -0
  43. package/dist/dev-server/not-found.js +129 -0
  44. package/dist/dev-server/request-handler.d.ts +1 -0
  45. package/dist/dev-server/request-handler.js +376 -0
  46. package/dist/dev-server/route-check.d.ts +9 -0
  47. package/dist/dev-server/route-check.js +100 -0
  48. package/dist/dev-server/watcher.d.ts +5 -0
  49. package/dist/dev-server/watcher.js +216 -0
  50. package/dist/dev-server.js +136 -883
  51. package/dist/download-result.d.ts +14 -0
  52. package/dist/download-result.js +148 -0
  53. package/dist/images/payload.js +4 -0
  54. package/dist/images/service.d.ts +13 -1
  55. package/dist/images/service.js +45 -15
  56. package/dist/manifest.d.ts +15 -1
  57. package/dist/manifest.js +70 -6
  58. package/dist/preview/create-preview-server.d.ts +18 -0
  59. package/dist/preview/create-preview-server.js +71 -0
  60. package/dist/preview/manifest.d.ts +42 -0
  61. package/dist/preview/manifest.js +57 -0
  62. package/dist/preview/paths.d.ts +3 -0
  63. package/dist/preview/paths.js +38 -0
  64. package/dist/preview/payload.d.ts +6 -0
  65. package/dist/preview/payload.js +34 -0
  66. package/dist/preview/request-handler.d.ts +1 -0
  67. package/dist/preview/request-handler.js +300 -0
  68. package/dist/preview/server-runner.d.ts +49 -0
  69. package/dist/preview/server-runner.js +220 -0
  70. package/dist/preview/server-script-runner-template.d.ts +1 -0
  71. package/dist/preview/server-script-runner-template.js +425 -0
  72. package/dist/preview.d.ts +5 -104
  73. package/dist/preview.js +7 -993
  74. package/dist/request-body.d.ts +0 -1
  75. package/dist/request-body.js +0 -6
  76. package/dist/resource-manifest.d.ts +16 -0
  77. package/dist/resource-manifest.js +53 -0
  78. package/dist/resource-response.d.ts +49 -0
  79. package/dist/resource-response.js +160 -0
  80. package/dist/resource-route-module.d.ts +15 -0
  81. package/dist/resource-route-module.js +129 -0
  82. package/dist/route-check-support.js +1 -1
  83. package/dist/server-contract/constants.d.ts +5 -0
  84. package/dist/server-contract/constants.js +5 -0
  85. package/dist/server-contract/export-validation.d.ts +5 -0
  86. package/dist/server-contract/export-validation.js +59 -0
  87. package/dist/server-contract/json-serializable.d.ts +1 -0
  88. package/dist/server-contract/json-serializable.js +52 -0
  89. package/dist/server-contract/resolve.d.ts +15 -0
  90. package/dist/server-contract/resolve.js +271 -0
  91. package/dist/server-contract/result-helpers.d.ts +51 -0
  92. package/dist/server-contract/result-helpers.js +59 -0
  93. package/dist/server-contract/route-result-validation.d.ts +2 -0
  94. package/dist/server-contract/route-result-validation.js +73 -0
  95. package/dist/server-contract/stage.d.ts +6 -0
  96. package/dist/server-contract/stage.js +22 -0
  97. package/dist/server-contract.d.ts +6 -54
  98. package/dist/server-contract.js +9 -301
  99. package/dist/server-error.d.ts +1 -1
  100. package/dist/server-error.js +2 -0
  101. package/dist/server-middleware.d.ts +10 -0
  102. package/dist/server-middleware.js +30 -0
  103. package/dist/server-output.d.ts +2 -1
  104. package/dist/server-output.js +72 -12
  105. package/dist/server-runtime/node-server.js +59 -7
  106. package/dist/server-runtime/route-render.d.ts +25 -1
  107. package/dist/server-runtime/route-render.js +81 -29
  108. package/dist/server-script-composition.d.ts +4 -2
  109. package/dist/server-script-composition.js +6 -3
  110. package/dist/static-export-paths.d.ts +3 -0
  111. package/dist/static-export-paths.js +160 -0
  112. 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,6 @@
1
+ /**
2
+ * @param {string} html
3
+ * @param {Record<string, unknown>} payload
4
+ * @returns {string}
5
+ */
6
+ export function injectSsrPayload(html: string, payload: Record<string, unknown>): string;
@@ -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;