@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.
- package/dist/adapters/adapter-netlify.js +0 -8
- package/dist/adapters/adapter-vercel.js +6 -14
- package/dist/adapters/copy-hosted-page-runtime.js +2 -1
- 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.js +46 -2
- 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 +123 -924
- package/dist/images/payload.js +4 -0
- package/dist/manifest.js +46 -1
- 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 -112
- package/dist/preview.js +7 -1119
- package/dist/resource-response.d.ts +15 -0
- package/dist/resource-response.js +91 -2
- 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 -62
- package/dist/server-contract.js +9 -493
- package/dist/server-middleware.d.ts +10 -0
- package/dist/server-middleware.js +30 -0
- package/dist/server-output.js +13 -1
- package/dist/server-runtime/node-server.js +25 -3
- package/package.json +3 -3
package/dist/images/payload.js
CHANGED
|
@@ -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,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
|
+
}
|