@zenithbuild/cli 0.7.5 → 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 (68) hide show
  1. package/dist/adapters/adapter-netlify.js +0 -8
  2. package/dist/adapters/adapter-vercel.js +6 -14
  3. package/dist/adapters/copy-hosted-page-runtime.js +2 -1
  4. package/dist/build/hoisted-code-transforms.d.ts +4 -1
  5. package/dist/build/hoisted-code-transforms.js +5 -3
  6. package/dist/build/page-ir-normalization.d.ts +1 -1
  7. package/dist/build/page-ir-normalization.js +33 -3
  8. package/dist/build/page-loop.js +46 -2
  9. package/dist/dev-build-session/helpers.d.ts +29 -0
  10. package/dist/dev-build-session/helpers.js +223 -0
  11. package/dist/dev-build-session/session.d.ts +24 -0
  12. package/dist/dev-build-session/session.js +204 -0
  13. package/dist/dev-build-session/state.d.ts +37 -0
  14. package/dist/dev-build-session/state.js +17 -0
  15. package/dist/dev-build-session.d.ts +1 -24
  16. package/dist/dev-build-session.js +1 -434
  17. package/dist/dev-server/css-state.d.ts +7 -0
  18. package/dist/dev-server/css-state.js +92 -0
  19. package/dist/dev-server/not-found.d.ts +23 -0
  20. package/dist/dev-server/not-found.js +129 -0
  21. package/dist/dev-server/request-handler.d.ts +1 -0
  22. package/dist/dev-server/request-handler.js +376 -0
  23. package/dist/dev-server/route-check.d.ts +9 -0
  24. package/dist/dev-server/route-check.js +100 -0
  25. package/dist/dev-server/watcher.d.ts +5 -0
  26. package/dist/dev-server/watcher.js +216 -0
  27. package/dist/dev-server.js +123 -924
  28. package/dist/images/payload.js +4 -0
  29. package/dist/manifest.js +46 -1
  30. package/dist/preview/create-preview-server.d.ts +18 -0
  31. package/dist/preview/create-preview-server.js +71 -0
  32. package/dist/preview/manifest.d.ts +42 -0
  33. package/dist/preview/manifest.js +57 -0
  34. package/dist/preview/paths.d.ts +3 -0
  35. package/dist/preview/paths.js +38 -0
  36. package/dist/preview/payload.d.ts +6 -0
  37. package/dist/preview/payload.js +34 -0
  38. package/dist/preview/request-handler.d.ts +1 -0
  39. package/dist/preview/request-handler.js +300 -0
  40. package/dist/preview/server-runner.d.ts +49 -0
  41. package/dist/preview/server-runner.js +220 -0
  42. package/dist/preview/server-script-runner-template.d.ts +1 -0
  43. package/dist/preview/server-script-runner-template.js +425 -0
  44. package/dist/preview.d.ts +5 -112
  45. package/dist/preview.js +7 -1119
  46. package/dist/resource-response.d.ts +15 -0
  47. package/dist/resource-response.js +91 -2
  48. package/dist/server-contract/constants.d.ts +5 -0
  49. package/dist/server-contract/constants.js +5 -0
  50. package/dist/server-contract/export-validation.d.ts +5 -0
  51. package/dist/server-contract/export-validation.js +59 -0
  52. package/dist/server-contract/json-serializable.d.ts +1 -0
  53. package/dist/server-contract/json-serializable.js +52 -0
  54. package/dist/server-contract/resolve.d.ts +15 -0
  55. package/dist/server-contract/resolve.js +271 -0
  56. package/dist/server-contract/result-helpers.d.ts +51 -0
  57. package/dist/server-contract/result-helpers.js +59 -0
  58. package/dist/server-contract/route-result-validation.d.ts +2 -0
  59. package/dist/server-contract/route-result-validation.js +73 -0
  60. package/dist/server-contract/stage.d.ts +6 -0
  61. package/dist/server-contract/stage.js +22 -0
  62. package/dist/server-contract.d.ts +6 -62
  63. package/dist/server-contract.js +9 -493
  64. package/dist/server-middleware.d.ts +10 -0
  65. package/dist/server-middleware.js +30 -0
  66. package/dist/server-output.js +13 -1
  67. package/dist/server-runtime/node-server.js +25 -3
  68. package/package.json +3 -3
@@ -18,6 +18,10 @@ function serializeInlineScriptJson(payload) {
18
18
  .replace(/\u2029/g, '\\u2029');
19
19
  }
20
20
  export function injectImageRuntimePayload(html, payload) {
21
+ // Only inject if the HTML contains Zenith image markers or unsafeHTML
22
+ if (!/data-zx-(data-zenith-image|unsafeHTML)/.test(html)) {
23
+ return html;
24
+ }
21
25
  const safePayload = createImageRuntimePayload(payload?.config || {}, payload?.localImages || {}, payload?.mode || 'passthrough', payload?.basePath || '/');
22
26
  const globalName = imageRuntimeGlobalName();
23
27
  const serialized = serializeInlineScriptJson(safePayload);
package/dist/manifest.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // ---------------------------------------------------------------------------
17
17
  import { readFileSync } from 'node:fs';
18
18
  import { readdir, stat } from 'node:fs/promises';
19
- import { join, relative, sep, basename, extname, dirname } from 'node:path';
19
+ import { join, relative, sep, basename, extname, dirname, resolve } from 'node:path';
20
20
  import { extractServerScript } from './build/server-script.js';
21
21
  import { analyzeResourceRouteModule, isResourceRouteFile } from './resource-route-module.js';
22
22
  import { composeServerScriptEnvelope, resolveAdjacentServerModules } from './server-script-composition.js';
@@ -47,6 +47,11 @@ import { validateStaticExportPaths } from './static-export-paths.js';
47
47
  */
48
48
  export async function generateManifest(pagesDir, extension = '.zen', options = {}) {
49
49
  const entries = await _scanDir(pagesDir, pagesDir, extension, options.compilerOpts || {});
50
+ const apiAliasState = _resolveSrcApiAliasState(pagesDir);
51
+ if (apiAliasState) {
52
+ const aliasEntries = await _scanResourceDir(apiAliasState.aliasDir, apiAliasState.srcDir);
53
+ entries.push(...aliasEntries);
54
+ }
50
55
  // Validate: no repeated param names in any single route
51
56
  for (const entry of entries) {
52
57
  _validateParams(entry.path);
@@ -97,6 +102,46 @@ async function _scanDir(dir, root, ext, compilerOpts) {
97
102
  }
98
103
  return entries;
99
104
  }
105
+ async function _scanResourceDir(dir, root) {
106
+ /** @type {ManifestEntry[]} */
107
+ const entries = [];
108
+ let items;
109
+ try {
110
+ items = await readdir(dir);
111
+ }
112
+ catch {
113
+ return entries;
114
+ }
115
+ items.sort();
116
+ for (const item of items) {
117
+ const fullPath = join(dir, item);
118
+ const info = await stat(fullPath);
119
+ if (info.isDirectory()) {
120
+ const nested = await _scanResourceDir(fullPath, root);
121
+ entries.push(...nested);
122
+ }
123
+ else if (isResourceRouteFile(item)) {
124
+ entries.push(analyzeResourceRouteModule(fullPath, root));
125
+ }
126
+ }
127
+ return entries;
128
+ }
129
+ function _resolveSrcApiAliasState(pagesDir) {
130
+ const resolvedPagesDir = resolve(pagesDir);
131
+ const srcDir = dirname(resolvedPagesDir);
132
+ if (basename(srcDir) !== 'src') {
133
+ return null;
134
+ }
135
+ const aliasDir = join(srcDir, 'api');
136
+ if (aliasDir === resolvedPagesDir ||
137
+ aliasDir.startsWith(`${resolvedPagesDir}${sep}`)) {
138
+ return null;
139
+ }
140
+ return {
141
+ aliasDir,
142
+ srcDir
143
+ };
144
+ }
100
145
  function buildPageManifestEntry({ fullPath, root, routePath, compilerOpts }) {
101
146
  const rawSource = readFileSync(fullPath, 'utf8');
102
147
  const inlineServerScript = extractServerScript(rawSource, fullPath, compilerOpts).serverScript;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Create and start a preview server.
3
+ *
4
+ * @param {{ distDir: string, port?: number, host?: string, logger?: object | null, config?: object, projectRoot?: string }} options
5
+ * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
6
+ */
7
+ export function createPreviewServer(options: {
8
+ distDir: string;
9
+ port?: number;
10
+ host?: string;
11
+ logger?: object | null;
12
+ config?: object;
13
+ projectRoot?: string;
14
+ }): Promise<{
15
+ server: import("http").Server;
16
+ port: number;
17
+ close: () => void;
18
+ }>;
@@ -0,0 +1,71 @@
1
+ import { createServer } from 'node:http';
2
+ import { resolve } from 'node:path';
3
+ import { normalizeBasePath } from '../base-path.js';
4
+ import { resolveBuildAdapter } from '../adapters/resolve-adapter.js';
5
+ import { isConfigKeyExplicit, isLoadedConfig, loadConfig, validateConfig } from '../config.js';
6
+ import { createTrustedOriginResolver } from '../request-origin.js';
7
+ import { supportsTargetRouteCheck } from '../route-check-support.js';
8
+ import { createSilentLogger } from '../ui/logger.js';
9
+ import { createPreviewRequestHandler } from './request-handler.js';
10
+ /**
11
+ * Create and start a preview server.
12
+ *
13
+ * @param {{ distDir: string, port?: number, host?: string, logger?: object | null, config?: object, projectRoot?: string }} options
14
+ * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
15
+ */
16
+ export async function createPreviewServer(options) {
17
+ const resolvedProjectRoot = options?.projectRoot ? resolve(options.projectRoot) : resolve(options.distDir, '..');
18
+ const loadedConfig = await loadConfig(resolvedProjectRoot);
19
+ const resolvedConfig = options?.config && typeof options.config === 'object'
20
+ ? (() => {
21
+ const overrideConfig = isLoadedConfig(options.config)
22
+ ? options.config
23
+ : validateConfig(options.config);
24
+ const mergedConfig = { ...loadedConfig };
25
+ for (const key of Object.keys(overrideConfig)) {
26
+ if (isConfigKeyExplicit(overrideConfig, key)) {
27
+ mergedConfig[key] = overrideConfig[key];
28
+ }
29
+ }
30
+ return mergedConfig;
31
+ })()
32
+ : loadedConfig;
33
+ const { distDir, port = 4000, host = '127.0.0.1', logger: providedLogger = null } = options;
34
+ const projectRoot = resolvedProjectRoot;
35
+ const config = resolvedConfig;
36
+ const logger = providedLogger || createSilentLogger();
37
+ const verboseLogging = logger.mode?.logLevel === 'verbose';
38
+ const configuredBasePath = normalizeBasePath(config.basePath || '/');
39
+ const resolvedTarget = resolveBuildAdapter(config).target;
40
+ const routeCheckEnabled = supportsTargetRouteCheck(resolvedTarget);
41
+ const isStaticExportTarget = resolvedTarget === 'static-export';
42
+ let actualPort = port;
43
+ const resolveServerOrigin = createTrustedOriginResolver({
44
+ host,
45
+ getPort: () => actualPort,
46
+ label: 'preview server'
47
+ });
48
+ const server = createServer(createPreviewRequestHandler({
49
+ distDir,
50
+ projectRoot,
51
+ config,
52
+ logger,
53
+ verboseLogging,
54
+ configuredBasePath,
55
+ routeCheckEnabled,
56
+ isStaticExportTarget,
57
+ serverOrigin: resolveServerOrigin
58
+ }));
59
+ return new Promise((resolveServer) => {
60
+ server.listen(port, host, () => {
61
+ actualPort = server.address().port;
62
+ resolveServer({
63
+ server,
64
+ port: actualPort,
65
+ close: () => {
66
+ server.close();
67
+ }
68
+ });
69
+ });
70
+ });
71
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @typedef {{
3
+ * path: string;
4
+ * output: string;
5
+ * server_script?: string | null;
6
+ * server_script_path?: string | null;
7
+ * prerender?: boolean;
8
+ * route_id?: string;
9
+ * pattern?: string;
10
+ * params_shape?: Record<string, string>;
11
+ * has_guard?: boolean;
12
+ * has_load?: boolean;
13
+ * guard_module_ref?: string | null;
14
+ * load_module_ref?: string | null;
15
+ * }} PreviewRoute
16
+ */
17
+ /**
18
+ * @param {string} distDir
19
+ * @returns {Promise<PreviewRoute[]>}
20
+ */
21
+ export function loadRouteManifest(distDir: string): Promise<PreviewRoute[]>;
22
+ export function loadRouteSurfaceState(distDir: any, fallbackBasePath?: string): Promise<{
23
+ basePath: string;
24
+ pageRoutes: any;
25
+ resourceRoutes: any[];
26
+ }>;
27
+ export const matchRoute: typeof matchManifestRoute;
28
+ export type PreviewRoute = {
29
+ path: string;
30
+ output: string;
31
+ server_script?: string | null;
32
+ server_script_path?: string | null;
33
+ prerender?: boolean;
34
+ route_id?: string;
35
+ pattern?: string;
36
+ params_shape?: Record<string, string>;
37
+ has_guard?: boolean;
38
+ has_load?: boolean;
39
+ guard_module_ref?: string | null;
40
+ load_module_ref?: string | null;
41
+ };
42
+ import { matchRoute as matchManifestRoute } from '../server/resolve-request-route.js';
@@ -0,0 +1,57 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { normalizeBasePath } from '../base-path.js';
4
+ import { loadResourceRouteManifest } from '../resource-manifest.js';
5
+ import { compareRouteSpecificity, matchRoute as matchManifestRoute } from '../server/resolve-request-route.js';
6
+ /**
7
+ * @typedef {{
8
+ * path: string;
9
+ * output: string;
10
+ * server_script?: string | null;
11
+ * server_script_path?: string | null;
12
+ * prerender?: boolean;
13
+ * route_id?: string;
14
+ * pattern?: string;
15
+ * params_shape?: Record<string, string>;
16
+ * has_guard?: boolean;
17
+ * has_load?: boolean;
18
+ * guard_module_ref?: string | null;
19
+ * load_module_ref?: string | null;
20
+ * }} PreviewRoute
21
+ */
22
+ /**
23
+ * @param {string} distDir
24
+ * @returns {Promise<PreviewRoute[]>}
25
+ */
26
+ export async function loadRouteManifest(distDir) {
27
+ const state = await loadRouteSurfaceState(distDir, '/');
28
+ return state.pageRoutes;
29
+ }
30
+ export async function loadRouteSurfaceState(distDir, fallbackBasePath = '/') {
31
+ const manifestPath = join(distDir, 'assets', 'router-manifest.json');
32
+ const resourceState = await loadResourceRouteManifest(distDir, normalizeBasePath(fallbackBasePath || '/'));
33
+ try {
34
+ const source = await readFile(manifestPath, 'utf8');
35
+ const parsed = JSON.parse(source);
36
+ const routes = Array.isArray(parsed?.routes) ? parsed.routes : [];
37
+ const basePath = normalizeBasePath(parsed?.base_path || resourceState.basePath || fallbackBasePath || '/');
38
+ return {
39
+ basePath,
40
+ pageRoutes: routes
41
+ .filter((entry) => entry &&
42
+ typeof entry === 'object' &&
43
+ typeof entry.path === 'string' &&
44
+ typeof entry.output === 'string')
45
+ .sort((a, b) => compareRouteSpecificity(a.path, b.path)),
46
+ resourceRoutes: Array.isArray(resourceState.routes) ? resourceState.routes : []
47
+ };
48
+ }
49
+ catch {
50
+ return {
51
+ basePath: normalizeBasePath(resourceState.basePath || fallbackBasePath || '/'),
52
+ pageRoutes: [],
53
+ resourceRoutes: Array.isArray(resourceState.routes) ? resourceState.routes : []
54
+ };
55
+ }
56
+ }
57
+ export const matchRoute = matchManifestRoute;
@@ -0,0 +1,3 @@
1
+ export function toStaticFilePath(distDir: any, pathname: any): string | null;
2
+ export function resolveWithinDist(distDir: any, requestPath: any): string | null;
3
+ export function fileExists(fullPath: any): Promise<boolean>;
@@ -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
+ }