@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
package/dist/preview.js CHANGED
@@ -3,997 +3,11 @@
3
3
  // ---------------------------------------------------------------------------
4
4
  // Preview server with manifest-driven route resolution.
5
5
  //
6
- // - Serves /dist assets directly.
7
- // - Resolves static and dynamic page routes via router-manifest.json.
8
- // - Executes non-prerender <script server> blocks per request and injects
9
- // serialized SSR payload via an inline script (`window.__zenith_ssr_data`).
6
+ // This file is intentionally a composition facade. Implementation details
7
+ // live in ./preview/* modules.
10
8
  // ---------------------------------------------------------------------------
11
- import { spawn } from 'node:child_process';
12
- import { createServer } from 'node:http';
13
- import { access, readFile } from 'node:fs/promises';
14
- import { extname, join, normalize, resolve, sep, dirname } from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
- import { appLocalRedirectLocation, imageEndpointPath, normalizeBasePath, routeCheckPath, stripBasePath } from './base-path.js';
17
- import { resolveBuildAdapter } from './adapters/resolve-adapter.js';
18
- import { isConfigKeyExplicit, isLoadedConfig, loadConfig, validateConfig } from './config.js';
19
- import { materializeImageMarkup } from './images/materialize.js';
20
- import { createImageRuntimePayload, injectImageRuntimePayload } from './images/payload.js';
21
- import { handleImageRequest } from './images/service.js';
22
- import { encodeRequestBodyBase64, readRequestBodyBuffer } from './request-body.js';
23
- import { createTrustedOriginResolver } from './request-origin.js';
24
- import { supportsTargetRouteCheck } from './route-check-support.js';
25
- import { clientFacingRouteMessage, defaultRouteDenyMessage, logServerException, sanitizeRouteResult } from './server-error.js';
26
- import { createSilentLogger } from './ui/logger.js';
27
- import { compareRouteSpecificity, matchRoute as matchManifestRoute, resolveRequestRoute } from './server/resolve-request-route.js';
28
- const __dirname = dirname(fileURLToPath(import.meta.url));
29
- const MIME_TYPES = {
30
- '.html': 'text/html',
31
- '.js': 'application/javascript',
32
- '.css': 'text/css',
33
- '.json': 'application/json',
34
- '.png': 'image/png',
35
- '.jpeg': 'image/jpeg',
36
- '.jpg': 'image/jpeg',
37
- '.svg': 'image/svg+xml',
38
- '.webp': 'image/webp',
39
- '.avif': 'image/avif',
40
- '.gif': 'image/gif'
41
- };
42
- const IMAGE_RUNTIME_TAG_RE = /<script\b[^>]*\bid=(["'])zenith-image-runtime\1[^>]*>[\s\S]*?<\/script>/i;
43
- const SERVER_SCRIPT_RUNNER = String.raw `
44
- import vm from 'node:vm';
45
- import fs from 'node:fs/promises';
46
- import path from 'node:path';
47
- import { pathToFileURL, fileURLToPath } from 'node:url';
48
-
49
- const source = process.env.ZENITH_SERVER_SOURCE || '';
50
- const sourcePath = process.env.ZENITH_SERVER_SOURCE_PATH || '';
51
- const params = JSON.parse(process.env.ZENITH_SERVER_PARAMS || '{}');
52
- const requestUrl = process.env.ZENITH_SERVER_REQUEST_URL || 'http://localhost/';
53
- const requestMethod = String(process.env.ZENITH_SERVER_REQUEST_METHOD || 'GET').toUpperCase();
54
- const requestHeaders = JSON.parse(process.env.ZENITH_SERVER_REQUEST_HEADERS || '{}');
55
- const routePattern = process.env.ZENITH_SERVER_ROUTE_PATTERN || '';
56
- const routeFile = process.env.ZENITH_SERVER_ROUTE_FILE || sourcePath || '';
57
- const routeId = process.env.ZENITH_SERVER_ROUTE_ID || routePattern || '';
58
- const guardOnly = process.env.ZENITH_SERVER_GUARD_ONLY === '1';
59
-
60
- if (!source.trim()) {
61
- process.stdout.write('null');
62
- process.exit(0);
63
- }
64
-
65
- let cachedTypeScript = undefined;
66
- async function loadTypeScript() {
67
- if (cachedTypeScript !== undefined) {
68
- return cachedTypeScript;
69
- }
70
- try {
71
- const mod = await import('typescript');
72
- cachedTypeScript = mod.default || mod;
73
- } catch {
74
- cachedTypeScript = null;
75
- }
76
- return cachedTypeScript;
77
- }
78
-
79
- async function transpileIfNeeded(filename, code) {
80
- const lower = String(filename || '').toLowerCase();
81
- const isTs =
82
- lower.endsWith('.ts') ||
83
- lower.endsWith('.tsx') ||
84
- lower.endsWith('.mts') ||
85
- lower.endsWith('.cts');
86
- if (!isTs) {
87
- return code;
88
- }
89
- const ts = await loadTypeScript();
90
- if (!ts || typeof ts.transpileModule !== 'function') {
91
- throw new Error('[zenith-preview] TypeScript is required to execute server modules that import .ts files');
92
- }
93
- const output = ts.transpileModule(code, {
94
- fileName: filename || 'server-script.ts',
95
- compilerOptions: {
96
- target: ts.ScriptTarget.ES2022,
97
- module: ts.ModuleKind.ESNext,
98
- moduleResolution: ts.ModuleResolutionKind.NodeNext
99
- },
100
- reportDiagnostics: false
101
- });
102
- return output.outputText;
103
- }
104
-
105
- async function exists(filePath) {
106
- try {
107
- await fs.access(filePath);
108
- return true;
109
- } catch {
110
- return false;
111
- }
112
- }
113
-
114
- async function resolveRelativeSpecifier(specifier, parentIdentifier) {
115
- let basePath = sourcePath;
116
- if (parentIdentifier && parentIdentifier.startsWith('file:')) {
117
- basePath = fileURLToPath(parentIdentifier);
118
- }
119
-
120
- const baseDir = basePath ? path.dirname(basePath) : process.cwd();
121
- const candidateBase = specifier.startsWith('file:')
122
- ? fileURLToPath(specifier)
123
- : path.resolve(baseDir, specifier);
124
-
125
- const candidates = [];
126
- if (path.extname(candidateBase)) {
127
- candidates.push(candidateBase);
128
- } else {
129
- candidates.push(candidateBase);
130
- candidates.push(candidateBase + '.ts');
131
- candidates.push(candidateBase + '.tsx');
132
- candidates.push(candidateBase + '.mts');
133
- candidates.push(candidateBase + '.cts');
134
- candidates.push(candidateBase + '.js');
135
- candidates.push(candidateBase + '.mjs');
136
- candidates.push(candidateBase + '.cjs');
137
- candidates.push(path.join(candidateBase, 'index.ts'));
138
- candidates.push(path.join(candidateBase, 'index.tsx'));
139
- candidates.push(path.join(candidateBase, 'index.mts'));
140
- candidates.push(path.join(candidateBase, 'index.cts'));
141
- candidates.push(path.join(candidateBase, 'index.js'));
142
- candidates.push(path.join(candidateBase, 'index.mjs'));
143
- candidates.push(path.join(candidateBase, 'index.cjs'));
144
- }
145
-
146
- for (const candidate of candidates) {
147
- if (await exists(candidate)) {
148
- return pathToFileURL(candidate).href;
149
- }
150
- }
151
-
152
- throw new Error(
153
- '[zenith-preview] Cannot resolve server import "' + specifier + '" from "' + (basePath || '<inline>') + '"'
154
- );
155
- }
156
-
157
- function isRelativeSpecifier(specifier) {
158
- return (
159
- specifier.startsWith('./') ||
160
- specifier.startsWith('../') ||
161
- specifier.startsWith('/') ||
162
- specifier.startsWith('file:')
163
- );
164
- }
165
-
166
- const safeRequestHeaders =
167
- requestHeaders && typeof requestHeaders === 'object'
168
- ? { ...requestHeaders }
169
- : {};
170
- function parseCookies(rawCookieHeader) {
171
- const out = Object.create(null);
172
- const raw = String(rawCookieHeader || '');
173
- if (!raw) return out;
174
- const pairs = raw.split(';');
175
- for (let i = 0; i < pairs.length; i++) {
176
- const part = pairs[i];
177
- const eq = part.indexOf('=');
178
- if (eq <= 0) continue;
179
- const key = part.slice(0, eq).trim();
180
- if (!key) continue;
181
- const value = part.slice(eq + 1).trim();
182
- try {
183
- out[key] = decodeURIComponent(value);
184
- } catch {
185
- out[key] = value;
186
- }
187
- }
188
- return out;
189
- }
190
- const cookieHeader = typeof safeRequestHeaders.cookie === 'string'
191
- ? safeRequestHeaders.cookie
192
- : '';
193
- const requestCookies = parseCookies(cookieHeader);
194
-
195
- function ctxAllow() {
196
- return { kind: 'allow' };
197
- }
198
- function ctxRedirect(location, status = 302) {
199
- return {
200
- kind: 'redirect',
201
- location: String(location || ''),
202
- status: Number.isInteger(status) ? status : 302
203
- };
204
- }
205
- function ctxDeny(status = 403, message = undefined) {
206
- return {
207
- kind: 'deny',
208
- status: Number.isInteger(status) ? status : 403,
209
- message: typeof message === 'string' ? message : undefined
210
- };
211
- }
212
- function ctxInvalid(payload, status = 400) {
213
- return {
214
- kind: 'invalid',
215
- data: payload,
216
- status: Number.isInteger(status) ? status : 400
217
- };
218
- }
219
- function ctxData(payload) {
220
- return {
221
- kind: 'data',
222
- data: payload
223
- };
224
- }
225
-
226
- const requestInit = {
227
- method: requestMethod,
228
- headers: new Headers(safeRequestHeaders)
229
- };
230
- const requestBodyBase64 = String(process.env.ZENITH_SERVER_REQUEST_BODY_BASE64 || '');
231
- if (requestMethod !== 'GET' && requestMethod !== 'HEAD' && requestBodyBase64.length > 0) {
232
- requestInit.body = Buffer.from(requestBodyBase64, 'base64');
233
- requestInit.duplex = 'half';
234
- }
235
- const requestSnapshot = new Request(requestUrl, requestInit);
236
- const routeParams = { ...params };
237
- const routeMeta = {
238
- id: routeId,
239
- pattern: routePattern,
240
- file: routeFile ? path.relative(process.cwd(), routeFile) : ''
241
- };
242
- const routeContext = {
243
- params: routeParams,
244
- url: new URL(requestUrl),
245
- headers: { ...safeRequestHeaders },
246
- cookies: requestCookies,
247
- request: requestSnapshot,
248
- method: requestMethod,
249
- route: routeMeta,
250
- env: {},
251
- action: null,
252
- auth: {
253
- async getSession(_ctx) {
254
- return null;
255
- },
256
- async requireSession(_ctx) {
257
- throw ctxRedirect('/login', 302);
258
- }
259
- },
260
- allow: ctxAllow,
261
- redirect: ctxRedirect,
262
- deny: ctxDeny,
263
- invalid: ctxInvalid,
264
- data: ctxData
265
- };
266
-
267
- const context = vm.createContext({
268
- params: routeParams,
269
- ctx: routeContext,
270
- fetch: globalThis.fetch,
271
- Headers: globalThis.Headers,
272
- Request: globalThis.Request,
273
- Response: globalThis.Response,
274
- URL,
275
- URLSearchParams,
276
- Buffer,
277
- console,
278
- process,
279
- setTimeout,
280
- clearTimeout,
281
- setInterval,
282
- clearInterval
283
- });
284
-
285
- const moduleCache = new Map();
286
- const syntheticModuleCache = new Map();
287
-
288
- async function createSyntheticModule(specifier) {
289
- if (syntheticModuleCache.has(specifier)) {
290
- return syntheticModuleCache.get(specifier);
291
- }
292
-
293
- const ns = await import(specifier);
294
- const exportNames = Object.keys(ns);
295
- const module = new vm.SyntheticModule(
296
- exportNames,
297
- function() {
298
- for (const key of exportNames) {
299
- this.setExport(key, ns[key]);
300
- }
301
- },
302
- { context }
303
- );
304
- await module.link(() => {
305
- throw new Error(
306
- '[zenith-preview] synthetic modules cannot contain nested imports: ' + specifier
307
- );
308
- });
309
- syntheticModuleCache.set(specifier, module);
310
- return module;
311
- }
312
-
313
- async function loadFileModule(moduleUrl) {
314
- if (moduleCache.has(moduleUrl)) {
315
- return moduleCache.get(moduleUrl);
316
- }
317
-
318
- const filename = fileURLToPath(moduleUrl);
319
- let code = await fs.readFile(filename, 'utf8');
320
- code = await transpileIfNeeded(filename, code);
321
- const module = new vm.SourceTextModule(code, {
322
- context,
323
- identifier: moduleUrl,
324
- initializeImportMeta(meta) {
325
- meta.url = moduleUrl;
326
- }
327
- });
328
-
329
- moduleCache.set(moduleUrl, module);
330
- await module.link((specifier, referencingModule) => {
331
- return linkModule(specifier, referencingModule.identifier);
332
- });
333
- return module;
334
- }
335
-
336
- async function linkModule(specifier, parentIdentifier) {
337
- if (!isRelativeSpecifier(specifier)) {
338
- return createSyntheticModule(specifier);
339
- }
340
- const resolvedUrl = await resolveRelativeSpecifier(specifier, parentIdentifier);
341
- return loadFileModule(resolvedUrl);
342
- }
343
-
344
- const allowed = new Set(['data', 'load', 'guard', 'action', 'ssr_data', 'props', 'ssr', 'prerender']);
345
- const prelude = "const params = globalThis.params;\n" +
346
- "const ctx = globalThis.ctx;\n" +
347
- "import { resolveRouteResult } from 'zenith:server-contract';\n" +
348
- "globalThis.resolveRouteResult = resolveRouteResult;\n";
349
- const entryIdentifier = sourcePath
350
- ? pathToFileURL(sourcePath).href
351
- : 'zenith:server-script';
352
- const entryTranspileFilename = sourcePath && sourcePath.toLowerCase().endsWith('.zen')
353
- ? sourcePath.replace(/\.zen$/i, '.ts')
354
- : (sourcePath || 'server-script.ts');
355
-
356
- const entryCode = await transpileIfNeeded(entryTranspileFilename, prelude + source);
357
- const entryModule = new vm.SourceTextModule(entryCode, {
358
- context,
359
- identifier: entryIdentifier,
360
- initializeImportMeta(meta) {
361
- meta.url = entryIdentifier;
362
- }
363
- });
364
-
365
- moduleCache.set(entryIdentifier, entryModule);
366
- await entryModule.link((specifier, referencingModule) => {
367
- if (specifier === 'zenith:server-contract') {
368
- const defaultPath = path.join(process.cwd(), 'node_modules', '@zenithbuild', 'cli', 'src', 'server-contract.js');
369
- const contractUrl = pathToFileURL(process.env.ZENITH_SERVER_CONTRACT_PATH || defaultPath).href;
370
- return loadFileModule(contractUrl).catch(() =>
371
- loadFileModule(pathToFileURL(defaultPath).href)
372
- );
373
- }
374
- return linkModule(specifier, referencingModule.identifier);
375
- });
376
- await entryModule.evaluate();
377
-
378
- const namespaceKeys = Object.keys(entryModule.namespace);
379
- for (const key of namespaceKeys) {
380
- if (!allowed.has(key)) {
381
- throw new Error('[zenith-preview] unsupported server export "' + key + '"');
382
- }
383
- }
384
-
385
- const exported = entryModule.namespace;
386
- try {
387
- const resolved = await context.resolveRouteResult({
388
- exports: exported,
389
- ctx: context.ctx,
390
- filePath: sourcePath || 'server_script',
391
- guardOnly: guardOnly
392
- });
393
-
394
- process.stdout.write(JSON.stringify(resolved || null));
395
- } catch (error) {
396
- const message = error instanceof Error
397
- ? (typeof error.stack === 'string' && error.stack.length > 0 ? error.stack : error.message)
398
- : String(error);
399
- process.stderr.write('[Zenith:Server] preview route execution failed\\n' + message + '\\n');
400
- process.stdout.write(
401
- JSON.stringify({
402
- __zenith_error: {
403
- status: 500,
404
- code: 'LOAD_FAILED'
405
- }
406
- })
407
- );
408
- }
409
- `;
410
- /**
411
- * Create and start a preview server.
412
- *
413
- * @param {{ distDir: string, port?: number, host?: string, logger?: object | null, config?: object, projectRoot?: string }} options
414
- * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
415
- */
416
- export async function createPreviewServer(options) {
417
- const resolvedProjectRoot = options?.projectRoot ? resolve(options.projectRoot) : resolve(options.distDir, '..');
418
- const loadedConfig = await loadConfig(resolvedProjectRoot);
419
- const resolvedConfig = options?.config && typeof options.config === 'object'
420
- ? (() => {
421
- const overrideConfig = isLoadedConfig(options.config)
422
- ? options.config
423
- : validateConfig(options.config);
424
- const mergedConfig = { ...loadedConfig };
425
- for (const key of Object.keys(overrideConfig)) {
426
- if (isConfigKeyExplicit(overrideConfig, key)) {
427
- mergedConfig[key] = overrideConfig[key];
428
- }
429
- }
430
- return mergedConfig;
431
- })()
432
- : loadedConfig;
433
- const { distDir, port = 4000, host = '127.0.0.1', logger: providedLogger = null } = options;
434
- const projectRoot = resolvedProjectRoot;
435
- const config = resolvedConfig;
436
- const logger = providedLogger || createSilentLogger();
437
- const verboseLogging = logger.mode?.logLevel === 'verbose';
438
- const configuredBasePath = normalizeBasePath(config.basePath || '/');
439
- const routeCheckEnabled = supportsTargetRouteCheck(resolveBuildAdapter(config).target);
440
- let actualPort = port;
441
- const resolveServerOrigin = createTrustedOriginResolver({
442
- host,
443
- getPort: () => actualPort,
444
- label: 'preview server'
445
- });
446
- async function loadImageManifest() {
447
- try {
448
- const manifestRaw = await readFile(join(distDir, '_zenith', 'image', 'manifest.json'), 'utf8');
449
- const parsed = JSON.parse(manifestRaw);
450
- return parsed && typeof parsed === 'object' ? parsed : {};
451
- }
452
- catch {
453
- return {};
454
- }
455
- }
456
- const server = createServer(async (req, res) => {
457
- const url = new URL(req.url, resolveServerOrigin());
458
- const { basePath, routes } = await loadRouteManifestState(distDir, configuredBasePath);
459
- const canonicalPath = stripBasePath(url.pathname, basePath);
460
- try {
461
- if (url.pathname === routeCheckPath(basePath)) {
462
- if (!routeCheckEnabled) {
463
- res.writeHead(501, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
464
- res.end(JSON.stringify({ error: 'route_check_unsupported' }));
465
- return;
466
- }
467
- // Security: Require explicitly designated header to prevent public oracle probing
468
- if (req.headers['x-zenith-route-check'] !== '1') {
469
- res.writeHead(403, { 'Content-Type': 'application/json' });
470
- res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
471
- return;
472
- }
473
- const targetPath = String(url.searchParams.get('path') || '/');
474
- // Security: Prevent protocol/domain injection in path
475
- if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
476
- res.writeHead(400, { 'Content-Type': 'application/json' });
477
- res.end(JSON.stringify({ error: 'invalid_path_format' }));
478
- return;
479
- }
480
- const targetUrl = new URL(targetPath, url.origin);
481
- if (targetUrl.origin !== url.origin) {
482
- res.writeHead(400, { 'Content-Type': 'application/json' });
483
- res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
484
- return;
485
- }
486
- const canonicalTargetPath = stripBasePath(targetUrl.pathname, basePath);
487
- if (canonicalTargetPath === null) {
488
- res.writeHead(404, { 'Content-Type': 'application/json' });
489
- res.end(JSON.stringify({ error: 'route_not_found' }));
490
- return;
491
- }
492
- const canonicalTargetUrl = new URL(targetUrl.toString());
493
- canonicalTargetUrl.pathname = canonicalTargetPath;
494
- const resolvedCheck = resolveRequestRoute(canonicalTargetUrl, routes);
495
- if (!resolvedCheck.matched || !resolvedCheck.route) {
496
- res.writeHead(404, { 'Content-Type': 'application/json' });
497
- res.end(JSON.stringify({ error: 'route_not_found' }));
498
- return;
499
- }
500
- const checkResult = await executeServerRoute({
501
- source: resolvedCheck.route.server_script || '',
502
- sourcePath: resolvedCheck.route.server_script_path || '',
503
- params: resolvedCheck.params,
504
- requestUrl: targetUrl.toString(),
505
- requestMethod: req.method || 'GET',
506
- requestHeaders: req.headers,
507
- routePattern: resolvedCheck.route.path,
508
- routeFile: resolvedCheck.route.server_script_path || '',
509
- routeId: resolvedCheck.route.route_id || routeIdFromSourcePath(resolvedCheck.route.server_script_path || ''),
510
- guardOnly: true
511
- });
512
- // Security: Enforce relative or same-origin redirects
513
- if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
514
- const loc = appLocalRedirectLocation(checkResult.result.location || '/', basePath);
515
- checkResult.result.location = loc;
516
- if (loc.includes('://') || loc.startsWith('//')) {
517
- try {
518
- const parsedLoc = new URL(loc);
519
- if (parsedLoc.origin !== targetUrl.origin) {
520
- checkResult.result.location = appLocalRedirectLocation('/', basePath);
521
- }
522
- }
523
- catch {
524
- checkResult.result.location = appLocalRedirectLocation('/', basePath);
525
- }
526
- }
527
- }
528
- res.writeHead(200, {
529
- 'Content-Type': 'application/json',
530
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
531
- 'Pragma': 'no-cache',
532
- 'Expires': '0',
533
- 'Vary': 'Cookie'
534
- });
535
- res.end(JSON.stringify({
536
- result: sanitizeRouteResult(checkResult?.result || checkResult),
537
- routeId: resolvedCheck.route.route_id || '',
538
- to: targetUrl.toString()
539
- }));
540
- return;
541
- }
542
- if (url.pathname === imageEndpointPath(basePath)) {
543
- await handleImageRequest(req, res, {
544
- requestUrl: url,
545
- projectRoot,
546
- config: config.images
547
- });
548
- return;
549
- }
550
- if (canonicalPath === null) {
551
- throw new Error('not found');
552
- }
553
- if (extname(canonicalPath) && extname(canonicalPath) !== '.html') {
554
- const staticPath = resolveWithinDist(distDir, canonicalPath);
555
- if (!staticPath || !(await fileExists(staticPath))) {
556
- throw new Error('not found');
557
- }
558
- const content = await readFile(staticPath);
559
- const mime = MIME_TYPES[extname(staticPath)] || 'application/octet-stream';
560
- res.writeHead(200, { 'Content-Type': mime });
561
- res.end(content);
562
- return;
563
- }
564
- const canonicalUrl = new URL(url.toString());
565
- canonicalUrl.pathname = canonicalPath;
566
- const resolved = resolveRequestRoute(canonicalUrl, routes);
567
- let htmlPath = null;
568
- if (resolved.matched && resolved.route) {
569
- if (verboseLogging) {
570
- logger.router(`${req.method || 'GET'} ${url.pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`);
571
- }
572
- const output = resolved.route.output.startsWith('/')
573
- ? resolved.route.output.slice(1)
574
- : resolved.route.output;
575
- htmlPath = resolveWithinDist(distDir, output);
576
- }
577
- else {
578
- htmlPath = toStaticFilePath(distDir, url.pathname);
579
- }
580
- if (!htmlPath || !(await fileExists(htmlPath))) {
581
- throw new Error('not found');
582
- }
583
- let ssrPayload = null;
584
- let routeExecution = null;
585
- if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
586
- try {
587
- const requestMethod = req.method || 'GET';
588
- const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
589
- ? null
590
- : await readRequestBodyBuffer(req);
591
- routeExecution = await executeServerRoute({
592
- source: resolved.route.server_script,
593
- sourcePath: resolved.route.server_script_path || '',
594
- params: resolved.params,
595
- requestUrl: url.toString(),
596
- requestMethod,
597
- requestHeaders: req.headers,
598
- requestBodyBase64: encodeRequestBodyBase64(requestBodyBuffer),
599
- routePattern: resolved.route.path,
600
- routeFile: resolved.route.server_script_path || '',
601
- routeId: resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '')
602
- });
603
- }
604
- catch (error) {
605
- logServerException('preview server route execution failed', error);
606
- ssrPayload = {
607
- __zenith_error: {
608
- status: 500,
609
- code: 'LOAD_FAILED',
610
- message: error instanceof Error ? error.message : String(error || '')
611
- }
612
- };
613
- }
614
- const trace = routeExecution?.trace || { guard: 'none', action: 'none', load: 'none' };
615
- const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
616
- if (verboseLogging) {
617
- logger.router(`${routeId} guard=${trace.guard} action=${trace.action} load=${trace.load}`);
618
- }
619
- const result = routeExecution?.result;
620
- if (result && result.kind === 'redirect') {
621
- const status = Number.isInteger(result.status) ? result.status : 302;
622
- res.writeHead(status, {
623
- Location: appLocalRedirectLocation(result.location, basePath),
624
- 'Cache-Control': 'no-store'
625
- });
626
- res.end('');
627
- return;
628
- }
629
- if (result && result.kind === 'deny') {
630
- const status = Number.isInteger(result.status) ? result.status : 403;
631
- res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
632
- res.end(clientFacingRouteMessage(status, result.message));
633
- return;
634
- }
635
- if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
636
- ssrPayload = result.data;
637
- }
638
- }
639
- let html = await readFile(htmlPath, 'utf8');
640
- if (resolved.matched) {
641
- html = await materializeImageMarkup({
642
- html,
643
- payload: createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint', basePath),
644
- imageMaterialization: Array.isArray(resolved.route?.image_materialization)
645
- ? resolved.route.image_materialization
646
- : []
647
- });
648
- }
649
- if (ssrPayload) {
650
- html = injectSsrPayload(html, ssrPayload);
651
- }
652
- if (!IMAGE_RUNTIME_TAG_RE.test(html)) {
653
- html = injectImageRuntimePayload(html, createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint', basePath));
654
- }
655
- res.writeHead(Number.isInteger(routeExecution?.status) ? routeExecution.status : 200, {
656
- 'Content-Type': 'text/html'
657
- });
658
- res.end(html);
659
- }
660
- catch {
661
- res.writeHead(404, { 'Content-Type': 'text/plain' });
662
- res.end('404 Not Found');
663
- }
664
- });
665
- return new Promise((resolveServer) => {
666
- server.listen(port, host, () => {
667
- actualPort = server.address().port;
668
- resolveServer({
669
- server,
670
- port: actualPort,
671
- close: () => {
672
- server.close();
673
- }
674
- });
675
- });
676
- });
677
- }
678
- /**
679
- * @typedef {{
680
- * path: string;
681
- * output: string;
682
- * server_script?: string | null;
683
- * server_script_path?: string | null;
684
- * prerender?: boolean;
685
- * route_id?: string;
686
- * pattern?: string;
687
- * params_shape?: Record<string, string>;
688
- * has_guard?: boolean;
689
- * has_load?: boolean;
690
- * guard_module_ref?: string | null;
691
- * load_module_ref?: string | null;
692
- * }} PreviewRoute
693
- */
694
- /**
695
- * @param {string} distDir
696
- * @returns {Promise<PreviewRoute[]>}
697
- */
698
- export async function loadRouteManifest(distDir) {
699
- const state = await loadRouteManifestState(distDir, '/');
700
- return state.routes;
701
- }
702
- async function loadRouteManifestState(distDir, fallbackBasePath = '/') {
703
- const manifestPath = join(distDir, 'assets', 'router-manifest.json');
704
- try {
705
- const source = await readFile(manifestPath, 'utf8');
706
- const parsed = JSON.parse(source);
707
- const routes = Array.isArray(parsed?.routes) ? parsed.routes : [];
708
- return {
709
- basePath: normalizeBasePath(parsed?.base_path || fallbackBasePath || '/'),
710
- routes: routes
711
- .filter((entry) => entry &&
712
- typeof entry === 'object' &&
713
- typeof entry.path === 'string' &&
714
- typeof entry.output === 'string')
715
- .sort((a, b) => compareRouteSpecificity(a.path, b.path))
716
- };
717
- }
718
- catch {
719
- return {
720
- basePath: normalizeBasePath(fallbackBasePath || '/'),
721
- routes: []
722
- };
723
- }
724
- }
725
- export const matchRoute = matchManifestRoute;
726
- /**
727
- * @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
728
- * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, action: string, load: string }, status?: number }>}
729
- */
730
- export async function executeServerRoute({ source, sourcePath, params, requestUrl, requestMethod, requestHeaders, requestBodyBase64, routePattern, routeFile, routeId, guardOnly = false }) {
731
- if (!source || !String(source).trim()) {
732
- return {
733
- result: { kind: 'data', data: {} },
734
- trace: { guard: 'none', action: 'none', load: 'none' }
735
- };
736
- }
737
- const payload = await spawnNodeServerRunner({
738
- source,
739
- sourcePath,
740
- params,
741
- requestUrl: requestUrl || 'http://localhost/',
742
- requestMethod: requestMethod || 'GET',
743
- requestHeaders: sanitizeRequestHeaders(requestHeaders || {}),
744
- requestBodyBase64: requestBodyBase64 || '',
745
- routePattern: routePattern || '',
746
- routeFile: routeFile || sourcePath || '',
747
- routeId: routeId || routeIdFromSourcePath(sourcePath || ''),
748
- guardOnly
749
- });
750
- if (payload === null || payload === undefined) {
751
- return {
752
- result: { kind: 'data', data: {} },
753
- trace: { guard: 'none', action: 'none', load: 'none' }
754
- };
755
- }
756
- if (typeof payload !== 'object' || Array.isArray(payload)) {
757
- throw new Error('[zenith-preview] server script payload must be an object');
758
- }
759
- const errorEnvelope = payload.__zenith_error;
760
- if (errorEnvelope && typeof errorEnvelope === 'object') {
761
- return {
762
- result: {
763
- kind: 'deny',
764
- status: 500,
765
- message: defaultRouteDenyMessage(500)
766
- },
767
- trace: { guard: 'none', action: 'none', load: 'deny' }
768
- };
769
- }
770
- const result = payload.result;
771
- const trace = payload.trace;
772
- if (result && typeof result === 'object' && !Array.isArray(result) && typeof result.kind === 'string') {
773
- return {
774
- result,
775
- trace: trace && typeof trace === 'object'
776
- ? {
777
- guard: String(trace.guard || 'none'),
778
- action: String(trace.action || 'none'),
779
- load: String(trace.load || 'none')
780
- }
781
- : { guard: 'none', action: 'none', load: 'none' },
782
- status: Number.isInteger(payload.status) ? payload.status : undefined
783
- };
784
- }
785
- return {
786
- result: {
787
- kind: 'data',
788
- data: payload
789
- },
790
- trace: { guard: 'none', action: 'none', load: 'data' }
791
- };
792
- }
793
- /**
794
- * @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
795
- * @returns {Promise<Record<string, unknown> | null>}
796
- */
797
- export async function executeServerScript(input) {
798
- const execution = await executeServerRoute(input);
799
- const result = execution?.result;
800
- if (!result || typeof result !== 'object') {
801
- return null;
802
- }
803
- if (result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
804
- return result.data;
805
- }
806
- if (result.kind === 'redirect') {
807
- return {
808
- __zenith_error: {
809
- status: Number.isInteger(result.status) ? result.status : 302,
810
- code: 'REDIRECT',
811
- message: `Redirect to ${String(result.location || '')}`
812
- }
813
- };
814
- }
815
- if (result.kind === 'deny') {
816
- const status = Number.isInteger(result.status) ? result.status : 403;
817
- return {
818
- __zenith_error: {
819
- status,
820
- code: status >= 500 ? 'LOAD_FAILED' : (status === 404 ? 'NOT_FOUND' : 'ACCESS_DENIED'),
821
- message: clientFacingRouteMessage(status, result.message)
822
- }
823
- };
824
- }
825
- return {};
826
- }
827
- /**
828
- * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl: string, requestMethod: string, requestHeaders: Record<string, string>, routePattern: string, routeFile: string, routeId: string }} input
829
- * @returns {Promise<unknown>}
830
- */
831
- function spawnNodeServerRunner(input) {
832
- return new Promise((resolvePromise, rejectPromise) => {
833
- const child = spawn(process.execPath, ['--experimental-vm-modules', '--input-type=module', '-e', SERVER_SCRIPT_RUNNER], {
834
- env: {
835
- ...process.env,
836
- ZENITH_SERVER_SOURCE: input.source,
837
- ZENITH_SERVER_SOURCE_PATH: input.sourcePath || '',
838
- ZENITH_SERVER_PARAMS: JSON.stringify(input.params || {}),
839
- ZENITH_SERVER_REQUEST_URL: input.requestUrl || 'http://localhost/',
840
- ZENITH_SERVER_REQUEST_METHOD: input.requestMethod || 'GET',
841
- ZENITH_SERVER_REQUEST_HEADERS: JSON.stringify(input.requestHeaders || {}),
842
- ZENITH_SERVER_REQUEST_BODY_BASE64: input.requestBodyBase64 || '',
843
- ZENITH_SERVER_ROUTE_PATTERN: input.routePattern || '',
844
- ZENITH_SERVER_ROUTE_FILE: input.routeFile || input.sourcePath || '',
845
- ZENITH_SERVER_ROUTE_ID: input.routeId || '',
846
- ZENITH_SERVER_GUARD_ONLY: input.guardOnly ? '1' : '',
847
- ZENITH_SERVER_CONTRACT_PATH: join(__dirname, 'server-contract.js')
848
- },
849
- stdio: ['ignore', 'pipe', 'pipe']
850
- });
851
- let stdout = '';
852
- let stderr = '';
853
- child.stdout.on('data', (chunk) => {
854
- stdout += String(chunk);
855
- });
856
- child.stderr.on('data', (chunk) => {
857
- stderr += String(chunk);
858
- });
859
- child.on('error', (error) => {
860
- rejectPromise(error);
861
- });
862
- child.on('close', (code) => {
863
- if (code !== 0) {
864
- rejectPromise(new Error(`[zenith-preview] server script execution failed (${code}): ${stderr.trim() || stdout.trim()}`));
865
- return;
866
- }
867
- const stderrOutput = stderr.trim();
868
- const internalErrorIndex = stderrOutput.indexOf('[Zenith:Server]');
869
- if (internalErrorIndex >= 0) {
870
- console.error(stderrOutput.slice(internalErrorIndex).trim());
871
- }
872
- const raw = stdout.trim();
873
- if (!raw || raw === 'null') {
874
- resolvePromise(null);
875
- return;
876
- }
877
- try {
878
- resolvePromise(JSON.parse(raw));
879
- }
880
- catch (error) {
881
- rejectPromise(new Error(`[zenith-preview] invalid server payload JSON: ${error instanceof Error ? error.message : String(error)}`));
882
- }
883
- });
884
- });
885
- }
886
- /**
887
- * @param {string} html
888
- * @param {Record<string, unknown>} payload
889
- * @returns {string}
890
- */
891
- export function injectSsrPayload(html, payload) {
892
- const serialized = serializeInlineScriptJson(payload);
893
- const scriptTag = `<script id="zenith-ssr-data">window.__zenith_ssr_data = ${serialized};</script>`;
894
- const existingTagRe = /<script\b[^>]*\bid=(["'])zenith-ssr-data\1[^>]*>[\s\S]*?<\/script>/i;
895
- if (existingTagRe.test(html)) {
896
- return html.replace(existingTagRe, scriptTag);
897
- }
898
- const headClose = html.match(/<\/head>/i);
899
- if (headClose) {
900
- return html.replace(/<\/head>/i, `${scriptTag}</head>`);
901
- }
902
- const bodyOpen = html.match(/<body\b[^>]*>/i);
903
- if (bodyOpen) {
904
- return html.replace(bodyOpen[0], `${bodyOpen[0]}${scriptTag}`);
905
- }
906
- return `${scriptTag}${html}`;
907
- }
908
- /**
909
- * @param {Record<string, unknown>} payload
910
- * @returns {string}
911
- */
912
- function serializeInlineScriptJson(payload) {
913
- return JSON.stringify(payload)
914
- .replace(/</g, '\\u003C')
915
- .replace(/>/g, '\\u003E')
916
- .replace(/\//g, '\\u002F')
917
- .replace(/\u2028/g, '\\u2028')
918
- .replace(/\u2029/g, '\\u2029');
919
- }
920
- export function toStaticFilePath(distDir, pathname) {
921
- let resolved = pathname;
922
- if (resolved === '/') {
923
- resolved = '/index.html';
924
- }
925
- else if (!extname(resolved)) {
926
- resolved += '/index.html';
927
- }
928
- return resolveWithinDist(distDir, resolved);
929
- }
930
- export function resolveWithinDist(distDir, requestPath) {
931
- let decoded = requestPath;
932
- try {
933
- decoded = decodeURIComponent(requestPath);
934
- }
935
- catch {
936
- return null;
937
- }
938
- const normalized = normalize(decoded).replace(/\\/g, '/');
939
- const relative = normalized.replace(/^\/+/, '');
940
- const root = resolve(distDir);
941
- const candidate = resolve(root, relative);
942
- if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
943
- return candidate;
944
- }
945
- return null;
946
- }
947
- /**
948
- * @param {Record<string, string | string[] | undefined>} headers
949
- * @returns {Record<string, string>}
950
- */
951
- function sanitizeRequestHeaders(headers) {
952
- const out = Object.create(null);
953
- const denyExact = new Set(['proxy-authorization', 'set-cookie']);
954
- const denyPrefixes = ['x-forwarded-', 'cf-'];
955
- for (const [rawKey, rawValue] of Object.entries(headers || {})) {
956
- const key = String(rawKey || '').toLowerCase();
957
- if (!key)
958
- continue;
959
- if (denyExact.has(key))
960
- continue;
961
- if (denyPrefixes.some((prefix) => key.startsWith(prefix)))
962
- continue;
963
- let value = '';
964
- if (Array.isArray(rawValue)) {
965
- value = rawValue.filter((entry) => entry !== undefined).map(String).join(', ');
966
- }
967
- else if (rawValue !== undefined) {
968
- value = String(rawValue);
969
- }
970
- out[key] = value;
971
- }
972
- return out;
973
- }
974
- /**
975
- * @param {string} sourcePath
976
- * @returns {string}
977
- */
978
- function routeIdFromSourcePath(sourcePath) {
979
- const normalized = String(sourcePath || '').replaceAll('\\', '/');
980
- const marker = '/pages/';
981
- const markerIndex = normalized.lastIndexOf(marker);
982
- let routeId = markerIndex >= 0
983
- ? normalized.slice(markerIndex + marker.length)
984
- : normalized.split('/').pop() || normalized;
985
- routeId = routeId.replace(/\.zen$/i, '');
986
- if (routeId.endsWith('/index')) {
987
- routeId = routeId.slice(0, -('/index'.length));
988
- }
989
- return routeId || 'index';
990
- }
991
- async function fileExists(fullPath) {
992
- try {
993
- await access(fullPath);
994
- return true;
995
- }
996
- catch {
997
- return false;
998
- }
999
- }
9
+ export { createPreviewServer } from './preview/create-preview-server.js';
10
+ export { loadRouteManifest, loadRouteSurfaceState, matchRoute } from './preview/manifest.js';
11
+ export { executeServerRoute, executeServerScript } from './preview/server-runner.js';
12
+ export { injectSsrPayload } from './preview/payload.js';
13
+ export { toStaticFilePath, resolveWithinDist } from './preview/paths.js';