@zenithbuild/cli 0.7.4 → 0.7.5

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 (58) 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 +56 -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 +70 -14
  10. package/dist/adapters/copy-hosted-page-runtime.d.ts +1 -0
  11. package/dist/adapters/copy-hosted-page-runtime.js +49 -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/page-loop-state.js +1 -1
  22. package/dist/build/server-script.d.ts +2 -1
  23. package/dist/build/server-script.js +7 -3
  24. package/dist/build-output-manifest.d.ts +3 -2
  25. package/dist/build-output-manifest.js +3 -0
  26. package/dist/build.js +29 -17
  27. package/dist/dev-server.js +79 -25
  28. package/dist/download-result.d.ts +14 -0
  29. package/dist/download-result.js +148 -0
  30. package/dist/images/service.d.ts +13 -1
  31. package/dist/images/service.js +45 -15
  32. package/dist/manifest.d.ts +15 -1
  33. package/dist/manifest.js +24 -5
  34. package/dist/preview.d.ts +11 -3
  35. package/dist/preview.js +188 -62
  36. package/dist/request-body.d.ts +0 -1
  37. package/dist/request-body.js +0 -6
  38. package/dist/resource-manifest.d.ts +16 -0
  39. package/dist/resource-manifest.js +53 -0
  40. package/dist/resource-response.d.ts +34 -0
  41. package/dist/resource-response.js +71 -0
  42. package/dist/resource-route-module.d.ts +15 -0
  43. package/dist/resource-route-module.js +129 -0
  44. package/dist/route-check-support.js +1 -1
  45. package/dist/server-contract.d.ts +24 -16
  46. package/dist/server-contract.js +217 -25
  47. package/dist/server-error.d.ts +1 -1
  48. package/dist/server-error.js +2 -0
  49. package/dist/server-output.d.ts +2 -1
  50. package/dist/server-output.js +59 -11
  51. package/dist/server-runtime/node-server.js +34 -4
  52. package/dist/server-runtime/route-render.d.ts +25 -1
  53. package/dist/server-runtime/route-render.js +81 -29
  54. package/dist/server-script-composition.d.ts +4 -2
  55. package/dist/server-script-composition.js +6 -3
  56. package/dist/static-export-paths.d.ts +3 -0
  57. package/dist/static-export-paths.js +160 -0
  58. package/package.json +3 -3
@@ -22,10 +22,11 @@ import { createStartupProfiler } from './startup-profile.js';
22
22
  import { createSilentLogger } from './ui/logger.js';
23
23
  import { readChangeFingerprint } from './dev-watch.js';
24
24
  import { createTrustedOriginResolver, publicHost } from './request-origin.js';
25
- import { encodeRequestBodyBase64, readRequestBodyBuffer } from './request-body.js';
25
+ import { readRequestBodyBuffer } from './request-body.js';
26
+ import { buildResourceResponseDescriptor } from './resource-response.js';
26
27
  import { supportsTargetRouteCheck } from './route-check-support.js';
27
28
  import { clientFacingRouteMessage, defaultRouteDenyMessage, logServerException, sanitizeRouteResult } from './server-error.js';
28
- import { executeServerRoute, injectSsrPayload, loadRouteManifest, resolveWithinDist, toStaticFilePath } from './preview.js';
29
+ import { executeServerRoute, injectSsrPayload, loadRouteSurfaceState, resolveWithinDist, toStaticFilePath } from './preview.js';
29
30
  import { materializeImageMarkup } from './images/materialize.js';
30
31
  import { injectImageRuntimePayload } from './images/payload.js';
31
32
  import { handleImageRequest } from './images/service.js';
@@ -46,6 +47,12 @@ const MIME_TYPES = {
46
47
  const IMAGE_RUNTIME_TAG_RE = new RegExp('<' + 'script\\b[^>]*\\bid=(["\'])zenith-image-runtime\\1[^>]*>[\\s\\S]*?<\\/' + 'script>', 'i');
47
48
  const EVENT_STREAM_MIME = ['text', 'event-stream'].join('/');
48
49
  const LEGACY_DEV_STREAM_PATH = ['/__zenith', '_hmr'].join('');
50
+ function appendSetCookieHeaders(headers, setCookies = []) {
51
+ if (Array.isArray(setCookies) && setCookies.length > 0) {
52
+ headers['Set-Cookie'] = setCookies.slice();
53
+ }
54
+ return headers;
55
+ }
49
56
  // Note: V0 HMR script injection has been moved to the runtime client.
50
57
  // This server purely hosts the V1 HMR contract endpoints.
51
58
  /**
@@ -60,7 +67,9 @@ export async function createDevServer(options) {
60
67
  const logger = providedLogger || createSilentLogger();
61
68
  const buildSession = createDevBuildSession({ pagesDir, outDir, config, logger });
62
69
  const configuredBasePath = normalizeBasePath(config.basePath || '/');
63
- const routeCheckEnabled = supportsTargetRouteCheck(resolveBuildAdapter(config).target);
70
+ const resolvedTarget = resolveBuildAdapter(config).target;
71
+ const routeCheckEnabled = supportsTargetRouteCheck(resolvedTarget);
72
+ const isStaticExportTarget = resolvedTarget === 'static-export';
64
73
  const resolvedPagesDir = resolve(pagesDir);
65
74
  const resolvedOutDir = resolve(outDir);
66
75
  const resolvedOutDirTmp = resolve(dirname(resolvedOutDir), `${basename(resolvedOutDir)}.tmp`);
@@ -102,7 +111,7 @@ export async function createDevServer(options) {
102
111
  getPort: () => actualPort,
103
112
  label: 'dev server'
104
113
  });
105
- let currentRoutes = [];
114
+ let currentRouteState = { pageRoutes: [], resourceRoutes: [] };
106
115
  const rebuildDebounceMs = 5;
107
116
  const queuedRebuildDebounceMs = 5;
108
117
  function _publicHost() {
@@ -371,22 +380,26 @@ export async function createDevServer(options) {
371
380
  return true;
372
381
  }
373
382
  async function _loadRoutesForRequests() {
374
- if (buildStatus === 'building' && Array.isArray(currentRoutes) && currentRoutes.length > 0) {
375
- return currentRoutes;
383
+ if (buildStatus === 'building' &&
384
+ ((Array.isArray(currentRouteState.pageRoutes) && currentRouteState.pageRoutes.length > 0) ||
385
+ (Array.isArray(currentRouteState.resourceRoutes) && currentRouteState.resourceRoutes.length > 0))) {
386
+ return currentRouteState;
376
387
  }
377
388
  try {
378
- const routes = await loadRouteManifest(outDir);
379
- if (Array.isArray(routes) && routes.length > 0) {
380
- currentRoutes = routes;
381
- return routes;
389
+ const routeState = await loadRouteSurfaceState(outDir, configuredBasePath);
390
+ if ((Array.isArray(routeState.pageRoutes) && routeState.pageRoutes.length > 0) ||
391
+ (Array.isArray(routeState.resourceRoutes) && routeState.resourceRoutes.length > 0)) {
392
+ currentRouteState = routeState;
393
+ return routeState;
382
394
  }
383
395
  }
384
396
  catch (error) {
385
- if (!(Array.isArray(currentRoutes) && currentRoutes.length > 0)) {
397
+ if (!(Array.isArray(currentRouteState.pageRoutes) && currentRouteState.pageRoutes.length > 0) &&
398
+ !(Array.isArray(currentRouteState.resourceRoutes) && currentRouteState.resourceRoutes.length > 0)) {
386
399
  throw error;
387
400
  }
388
401
  }
389
- return currentRoutes;
402
+ return currentRouteState;
390
403
  }
391
404
  function _broadcastEvent(type, payload = {}) {
392
405
  const eventBuildId = Number.isInteger(payload.buildId) ? payload.buildId : buildId;
@@ -419,7 +432,7 @@ export async function createDevServer(options) {
419
432
  logger.build('Initial build (id=0)', { onceKey: 'dev-initial-build' });
420
433
  const initialBuild = await buildSession.build();
421
434
  const cssReady = await _syncCssStateFromBuild(initialBuild, buildId);
422
- currentRoutes = await loadRouteManifest(outDir);
435
+ currentRouteState = await loadRouteSurfaceState(outDir, configuredBasePath);
423
436
  buildStatus = 'ok';
424
437
  buildError = null;
425
438
  lastBuildMs = Date.now();
@@ -438,7 +451,8 @@ export async function createDevServer(options) {
438
451
  status: buildStatus,
439
452
  durationMs,
440
453
  cssReady,
441
- routes: Array.isArray(currentRoutes) ? currentRoutes.length : 0
454
+ routes: (Array.isArray(currentRouteState.pageRoutes) ? currentRouteState.pageRoutes.length : 0) +
455
+ (Array.isArray(currentRouteState.resourceRoutes) ? currentRouteState.resourceRoutes.length : 0)
442
456
  });
443
457
  }
444
458
  catch (err) {
@@ -579,6 +593,9 @@ export async function createDevServer(options) {
579
593
  return;
580
594
  }
581
595
  if (pathname === imageEndpointPath(configuredBasePath)) {
596
+ if (isStaticExportTarget) {
597
+ throw new Error('not found');
598
+ }
582
599
  await handleImageRequest(req, res, {
583
600
  requestUrl: url,
584
601
  projectRoot,
@@ -629,7 +646,7 @@ export async function createDevServer(options) {
629
646
  const canonicalTargetUrl = new URL(targetUrl.toString());
630
647
  canonicalTargetUrl.pathname = canonicalTargetPath;
631
648
  const routes = await _loadRoutesForRequests();
632
- const resolvedCheck = resolveRequestRoute(canonicalTargetUrl, routes);
649
+ const resolvedCheck = resolveRequestRoute(canonicalTargetUrl, routes.pageRoutes || []);
633
650
  if (!resolvedCheck.matched || !resolvedCheck.route) {
634
651
  res.writeHead(404, { 'Content-Type': 'application/json' });
635
652
  res.end(JSON.stringify({ error: 'route_not_found' }));
@@ -722,9 +739,14 @@ export async function createDevServer(options) {
722
739
  }
723
740
  const requestExt = extname(canonicalPath);
724
741
  if (requestExt && requestExt !== '.html') {
725
- const assetPath = join(outDir, canonicalPath);
742
+ const assetPath = isStaticExportTarget
743
+ ? resolveWithinDist(outDir, pathname)
744
+ : join(outDir, canonicalPath);
726
745
  resolvedPathFor404 = assetPath;
727
746
  staticRootFor404 = outDir;
747
+ if (!assetPath) {
748
+ throw new Error('not found');
749
+ }
728
750
  const asset = await _readFileForRequest(assetPath);
729
751
  const mime = MIME_TYPES[requestExt] || 'application/octet-stream';
730
752
  res.writeHead(200, { 'Content-Type': mime });
@@ -734,9 +756,40 @@ export async function createDevServer(options) {
734
756
  const routes = await _loadRoutesForRequests();
735
757
  const canonicalUrl = new URL(url.toString());
736
758
  canonicalUrl.pathname = canonicalPath;
737
- const resolved = resolveRequestRoute(canonicalUrl, routes);
759
+ const resolvedResource = resolveRequestRoute(canonicalUrl, routes.resourceRoutes || []);
760
+ if (resolvedResource.matched && resolvedResource.route) {
761
+ const requestMethod = req.method || 'GET';
762
+ const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
763
+ ? null
764
+ : await readRequestBodyBuffer(req);
765
+ const execution = await executeServerRoute({
766
+ source: resolvedResource.route.server_script || '',
767
+ sourcePath: resolvedResource.route.server_script_path || '',
768
+ params: resolvedResource.params,
769
+ requestUrl: url.toString(),
770
+ requestMethod,
771
+ requestHeaders: req.headers,
772
+ requestBodyBuffer,
773
+ routePattern: resolvedResource.route.path,
774
+ routeFile: resolvedResource.route.server_script_path || '',
775
+ routeId: resolvedResource.route.route_id || '',
776
+ routeKind: 'resource'
777
+ });
778
+ const descriptor = buildResourceResponseDescriptor(execution?.result, configuredBasePath, Array.isArray(execution?.setCookies) ? execution.setCookies : []);
779
+ res.writeHead(descriptor.status, appendSetCookieHeaders(descriptor.headers, descriptor.setCookies));
780
+ if ((req.method || 'GET').toUpperCase() === 'HEAD') {
781
+ res.end();
782
+ return;
783
+ }
784
+ res.end(descriptor.body);
785
+ return;
786
+ }
787
+ const resolved = resolveRequestRoute(canonicalUrl, routes.pageRoutes || []);
738
788
  let filePath = null;
739
- if (resolved.matched && resolved.route) {
789
+ if (isStaticExportTarget) {
790
+ filePath = toStaticFilePath(outDir, pathname);
791
+ }
792
+ else if (resolved.matched && resolved.route) {
740
793
  if (verboseLogging) {
741
794
  logger.router(`${req.method || 'GET'} ${pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`);
742
795
  }
@@ -768,7 +821,7 @@ export async function createDevServer(options) {
768
821
  requestUrl: url.toString(),
769
822
  requestMethod,
770
823
  requestHeaders: req.headers,
771
- requestBodyBase64: encodeRequestBodyBase64(requestBodyBuffer),
824
+ requestBodyBuffer,
772
825
  routePattern: resolved.route.path,
773
826
  routeFile: resolved.route.server_script_path || '',
774
827
  routeId: resolved.route.route_id || ''
@@ -786,22 +839,23 @@ export async function createDevServer(options) {
786
839
  }
787
840
  const trace = routeExecution?.trace || { guard: 'none', action: 'none', load: 'none' };
788
841
  const routeId = resolved.route.route_id || '';
842
+ const setCookies = Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : [];
789
843
  if (verboseLogging) {
790
844
  logger.router(`${routeId || resolved.route.path} guard=${trace.guard} action=${trace.action} load=${trace.load}`);
791
845
  }
792
846
  const result = routeExecution?.result;
793
847
  if (result && result.kind === 'redirect') {
794
848
  const status = Number.isInteger(result.status) ? result.status : 302;
795
- res.writeHead(status, {
849
+ res.writeHead(status, appendSetCookieHeaders({
796
850
  Location: appLocalRedirectLocation(result.location, configuredBasePath),
797
851
  'Cache-Control': 'no-store'
798
- });
852
+ }, setCookies));
799
853
  res.end('');
800
854
  return;
801
855
  }
802
856
  if (result && result.kind === 'deny') {
803
857
  const status = Number.isInteger(result.status) ? result.status : 403;
804
- res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
858
+ res.writeHead(status, appendSetCookieHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }, setCookies));
805
859
  res.end(clientFacingRouteMessage(status, result.message));
806
860
  return;
807
861
  }
@@ -825,9 +879,9 @@ export async function createDevServer(options) {
825
879
  if (!IMAGE_RUNTIME_TAG_RE.test(content)) {
826
880
  content = injectImageRuntimePayload(content, buildSession.getImageRuntimePayload());
827
881
  }
828
- res.writeHead(Number.isInteger(routeExecution?.status) ? routeExecution.status : 200, {
882
+ res.writeHead(Number.isInteger(routeExecution?.status) ? routeExecution.status : 200, appendSetCookieHeaders({
829
883
  'Content-Type': 'text/html'
830
- });
884
+ }, Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : []));
831
885
  res.end(content);
832
886
  }
833
887
  catch (error) {
@@ -950,7 +1004,7 @@ export async function createDevServer(options) {
950
1004
  const buildResult = await buildSession.build({ changedFiles, logger });
951
1005
  const cssReady = await _syncCssStateFromBuild(buildResult, cycleBuildId);
952
1006
  if (!onlyCss) {
953
- currentRoutes = await loadRouteManifest(outDir);
1007
+ currentRouteState = await loadRouteSurfaceState(outDir, configuredBasePath);
954
1008
  }
955
1009
  const cssChanged = cssReady && (currentCssAssetPath !== previousCssAssetPath ||
956
1010
  currentCssContent !== previousCssContent);
@@ -0,0 +1,14 @@
1
+ export function buildAttachmentContentDisposition(filename: any): string;
2
+ export function createDownloadResult(body: any, options?: {}): {
3
+ kind: string;
4
+ body: any;
5
+ bodyEncoding: string;
6
+ bodySize: number;
7
+ filename: string;
8
+ contentType: string;
9
+ status: number;
10
+ };
11
+ export function assertValidDownloadResult(value: any, where?: string): void;
12
+ export function decodeDownloadResultBody(result: any, where?: string): Buffer<ArrayBuffer>;
13
+ export const DOWNLOAD_PAYLOAD_LIMIT_BYTES: number;
14
+ export const DOWNLOAD_DEFAULT_CONTENT_TYPE: "application/octet-stream";
@@ -0,0 +1,148 @@
1
+ export const DOWNLOAD_PAYLOAD_LIMIT_BYTES = 5 * 1024 * 1024;
2
+ export const DOWNLOAD_DEFAULT_CONTENT_TYPE = 'application/octet-stream';
3
+ const CONTROL_CHAR_RE = /[\0-\x1F\x7F]/;
4
+ const PATH_SEPARATOR_RE = /[\\/]/;
5
+ function formatWhere(where = 'download(...)') {
6
+ return String(where || 'download(...)');
7
+ }
8
+ function normalizeFilename(filename, where = 'download(...)') {
9
+ const label = formatWhere(where);
10
+ const value = String(filename ?? '').trim();
11
+ if (!value) {
12
+ throw new Error(`[Zenith] ${label}: download filename is required.`);
13
+ }
14
+ if (CONTROL_CHAR_RE.test(value) || PATH_SEPARATOR_RE.test(value)) {
15
+ throw new Error(`[Zenith] ${label}: download filename must not contain path separators or control characters.`);
16
+ }
17
+ return value;
18
+ }
19
+ function normalizeContentType(contentType, where = 'download(...)') {
20
+ const label = formatWhere(where);
21
+ if (contentType === undefined || contentType === null) {
22
+ return DOWNLOAD_DEFAULT_CONTENT_TYPE;
23
+ }
24
+ const value = String(contentType).trim();
25
+ if (!value) {
26
+ throw new Error(`[Zenith] ${label}: download contentType must be a non-empty string when provided.`);
27
+ }
28
+ if (CONTROL_CHAR_RE.test(value)) {
29
+ throw new Error(`[Zenith] ${label}: download contentType must not contain control characters.`);
30
+ }
31
+ return value;
32
+ }
33
+ function isBlobLike(value) {
34
+ return (typeof Blob !== 'undefined' && value instanceof Blob)
35
+ || (typeof File !== 'undefined' && value instanceof File);
36
+ }
37
+ function encodeBuffer(buffer, encoding) {
38
+ return encoding === 'utf8'
39
+ ? buffer.toString('utf8')
40
+ : buffer.toString('base64');
41
+ }
42
+ function decodeBody(body, bodyEncoding, where = 'download(...)') {
43
+ const label = formatWhere(where);
44
+ if (typeof body !== 'string') {
45
+ throw new Error(`[Zenith] ${label}: download body must be a string after normalization.`);
46
+ }
47
+ if (bodyEncoding === 'utf8') {
48
+ return Buffer.from(body, 'utf8');
49
+ }
50
+ if (bodyEncoding === 'base64') {
51
+ return Buffer.from(body, 'base64');
52
+ }
53
+ throw new Error(`[Zenith] ${label}: download bodyEncoding must be "utf8" or "base64".`);
54
+ }
55
+ function normalizeBody(body, where = 'download(...)') {
56
+ const label = formatWhere(where);
57
+ if (isBlobLike(body)) {
58
+ throw new Error(`[Zenith] ${label}: download body must be string, Uint8Array, ArrayBuffer, or Buffer-compatible bytes.`);
59
+ }
60
+ if (typeof body === 'string') {
61
+ const size = Buffer.byteLength(body, 'utf8');
62
+ if (size > DOWNLOAD_PAYLOAD_LIMIT_BYTES) {
63
+ throw new Error(`[Zenith] ${label}: download payload exceeds ${DOWNLOAD_PAYLOAD_LIMIT_BYTES} bytes.`);
64
+ }
65
+ return {
66
+ body: body,
67
+ bodyEncoding: 'utf8',
68
+ bodySize: size
69
+ };
70
+ }
71
+ if (body instanceof ArrayBuffer) {
72
+ const buffer = Buffer.from(body);
73
+ if (buffer.byteLength > DOWNLOAD_PAYLOAD_LIMIT_BYTES) {
74
+ throw new Error(`[Zenith] ${label}: download payload exceeds ${DOWNLOAD_PAYLOAD_LIMIT_BYTES} bytes.`);
75
+ }
76
+ return {
77
+ body: encodeBuffer(buffer, 'base64'),
78
+ bodyEncoding: 'base64',
79
+ bodySize: buffer.byteLength
80
+ };
81
+ }
82
+ if (ArrayBuffer.isView(body)) {
83
+ const buffer = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
84
+ if (buffer.byteLength > DOWNLOAD_PAYLOAD_LIMIT_BYTES) {
85
+ throw new Error(`[Zenith] ${label}: download payload exceeds ${DOWNLOAD_PAYLOAD_LIMIT_BYTES} bytes.`);
86
+ }
87
+ return {
88
+ body: encodeBuffer(buffer, 'base64'),
89
+ bodyEncoding: 'base64',
90
+ bodySize: buffer.byteLength
91
+ };
92
+ }
93
+ throw new Error(`[Zenith] ${label}: download body must be string, Uint8Array, ArrayBuffer, or Buffer-compatible bytes.`);
94
+ }
95
+ function buildAsciiFilename(filename) {
96
+ const replaced = Array.from(String(filename || '')).map((char) => {
97
+ const code = char.charCodeAt(0);
98
+ if (code < 0x20 || code > 0x7E || char === '"' || char === '\\') {
99
+ return '_';
100
+ }
101
+ return char;
102
+ }).join('');
103
+ return replaced || 'download';
104
+ }
105
+ export function buildAttachmentContentDisposition(filename) {
106
+ const safeFilename = normalizeFilename(filename, 'download result');
107
+ const asciiFilename = buildAsciiFilename(safeFilename);
108
+ const encodedFilename = encodeURIComponent(safeFilename)
109
+ .replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
110
+ .replace(/\*/g, '%2A');
111
+ return `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`;
112
+ }
113
+ export function createDownloadResult(body, options = {}) {
114
+ const filename = normalizeFilename(options?.filename, 'download(...)');
115
+ const contentType = normalizeContentType(options?.contentType, 'download(...)');
116
+ const normalized = normalizeBody(body, 'download(...)');
117
+ return {
118
+ kind: 'download',
119
+ body: normalized.body,
120
+ bodyEncoding: normalized.bodyEncoding,
121
+ bodySize: normalized.bodySize,
122
+ filename,
123
+ contentType,
124
+ status: 200
125
+ };
126
+ }
127
+ export function assertValidDownloadResult(value, where = 'download result') {
128
+ const label = formatWhere(where);
129
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
130
+ throw new Error(`[Zenith] ${label}: download result must be an object.`);
131
+ }
132
+ normalizeFilename(value.filename, label);
133
+ normalizeContentType(value.contentType, label);
134
+ if (value.status !== 200) {
135
+ throw new Error(`[Zenith] ${label}: download status is fixed to 200 in this milestone.`);
136
+ }
137
+ if (!Number.isInteger(value.bodySize) || value.bodySize < 0 || value.bodySize > DOWNLOAD_PAYLOAD_LIMIT_BYTES) {
138
+ throw new Error(`[Zenith] ${label}: download bodySize must be an integer between 0 and ${DOWNLOAD_PAYLOAD_LIMIT_BYTES}.`);
139
+ }
140
+ const buffer = decodeBody(value.body, value.bodyEncoding, label);
141
+ if (buffer.byteLength !== value.bodySize) {
142
+ throw new Error(`[Zenith] ${label}: download bodySize does not match the normalized body.`);
143
+ }
144
+ }
145
+ export function decodeDownloadResultBody(result, where = 'download result') {
146
+ assertValidDownloadResult(result, where);
147
+ return decodeBody(result.body, result.bodyEncoding, where);
148
+ }
@@ -1,4 +1,16 @@
1
1
  export function buildImageArtifacts(options: any): Promise<{
2
2
  manifest: {};
3
3
  }>;
4
- export function handleImageRequest(req: any, res: any, options: any): Promise<boolean>;
4
+ /**
5
+ * @param {Request | { url?: string } | null | undefined} request
6
+ * @param {{ requestUrl?: URL | string, projectRoot: string, config?: Record<string, unknown> }} options
7
+ * @returns {Promise<Response>}
8
+ */
9
+ export function handleImageFetchRequest(request: Request | {
10
+ url?: string;
11
+ } | null | undefined, options: {
12
+ requestUrl?: URL | string;
13
+ projectRoot: string;
14
+ config?: Record<string, unknown>;
15
+ }): Promise<Response>;
16
+ export function handleImageRequest(_req: any, res: any, options: any): Promise<boolean>;
@@ -220,7 +220,29 @@ function remoteCachePaths(cacheDir, cacheKey) {
220
220
  metaPath: join(cacheDir, `${cacheKey}.json`)
221
221
  };
222
222
  }
223
- export async function handleImageRequest(req, res, options) {
223
+ function createJsonResponse(status, payload) {
224
+ return new Response(JSON.stringify(payload), {
225
+ status,
226
+ headers: {
227
+ 'Content-Type': 'application/json'
228
+ }
229
+ });
230
+ }
231
+ function createBufferResponse(status, contentType, buffer, cacheSeconds) {
232
+ return new Response(buffer, {
233
+ status,
234
+ headers: {
235
+ 'Content-Type': contentType,
236
+ 'Cache-Control': `public, max-age=${cacheSeconds}`
237
+ }
238
+ });
239
+ }
240
+ async function sendResponse(res, response) {
241
+ res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
242
+ const body = await response.arrayBuffer();
243
+ res.end(Buffer.from(body));
244
+ }
245
+ async function createImageResponse(options) {
224
246
  const { requestUrl, projectRoot, config: rawConfig } = options;
225
247
  const config = normalizeImageConfig(rawConfig);
226
248
  const url = requestUrl instanceof URL ? requestUrl : new URL(String(requestUrl));
@@ -230,14 +252,10 @@ export async function handleImageRequest(req, res, options) {
230
252
  const format = normalizeImageFormat(url.searchParams.get('f') || '');
231
253
  const quality = Number.isInteger(requestedQuality) && requestedQuality > 0 ? requestedQuality : config.quality;
232
254
  if (!remoteUrl) {
233
- res.writeHead(400, { 'Content-Type': 'application/json' });
234
- res.end(JSON.stringify({ error: 'missing_url' }));
235
- return true;
255
+ return createJsonResponse(400, { error: 'missing_url' });
236
256
  }
237
257
  if (!Number.isInteger(width) || width <= 0) {
238
- res.writeHead(400, { 'Content-Type': 'application/json' });
239
- res.end(JSON.stringify({ error: 'invalid_width' }));
240
- return true;
258
+ return createJsonResponse(400, { error: 'invalid_width' });
241
259
  }
242
260
  try {
243
261
  const remote = await validateRemoteTarget(remoteUrl, config);
@@ -253,8 +271,7 @@ export async function handleImageRequest(req, res, options) {
253
271
  const contentType = typeof parsedMeta?.contentType === 'string'
254
272
  ? parsedMeta.contentType
255
273
  : mimeTypeForFormat(format || 'jpg');
256
- sendBuffer(res, 200, contentType, cached, config.minimumCacheTTL);
257
- return true;
274
+ return createBufferResponse(200, contentType, cached, config.minimumCacheTTL);
258
275
  }
259
276
  const response = await fetch(remote, {
260
277
  headers: {
@@ -288,15 +305,28 @@ export async function handleImageRequest(req, res, options) {
288
305
  format: targetFormat
289
306
  }, null, 2)}\n`, 'utf8')
290
307
  ]);
291
- sendBuffer(res, 200, mimeTypeForFormat(targetFormat), output, config.minimumCacheTTL);
292
- return true;
308
+ return createBufferResponse(200, mimeTypeForFormat(targetFormat), output, config.minimumCacheTTL);
293
309
  }
294
310
  catch (error) {
295
- res.writeHead(400, { 'Content-Type': 'application/json' });
296
- res.end(JSON.stringify({
311
+ return createJsonResponse(400, {
297
312
  error: 'image_request_failed',
298
313
  message: error instanceof Error ? error.message : String(error)
299
- }));
300
- return true;
314
+ });
301
315
  }
302
316
  }
317
+ /**
318
+ * @param {Request | { url?: string } | null | undefined} request
319
+ * @param {{ requestUrl?: URL | string, projectRoot: string, config?: Record<string, unknown> }} options
320
+ * @returns {Promise<Response>}
321
+ */
322
+ export async function handleImageFetchRequest(request, options) {
323
+ return createImageResponse({
324
+ ...options,
325
+ requestUrl: request?.url || options?.requestUrl
326
+ });
327
+ }
328
+ export async function handleImageRequest(_req, res, options) {
329
+ const response = await createImageResponse(options);
330
+ await sendResponse(res, response);
331
+ return true;
332
+ }
@@ -2,9 +2,16 @@
2
2
  * @typedef {{
3
3
  * path: string,
4
4
  * file: string,
5
+ * route_kind?: 'page' | 'resource',
5
6
  * path_kind: 'static' | 'dynamic',
6
7
  * render_mode: 'prerender' | 'server',
7
- * params: string[]
8
+ * params: string[],
9
+ * server_script?: string,
10
+ * server_script_path?: string,
11
+ * has_guard?: boolean,
12
+ * has_load?: boolean,
13
+ * has_action?: boolean,
14
+ * export_paths?: string[]
8
15
  * }} ManifestEntry
9
16
  */
10
17
  /**
@@ -29,7 +36,14 @@ export function serializeManifest(entries: ManifestEntry[]): string;
29
36
  export type ManifestEntry = {
30
37
  path: string;
31
38
  file: string;
39
+ route_kind?: "page" | "resource";
32
40
  path_kind: "static" | "dynamic";
33
41
  render_mode: "prerender" | "server";
34
42
  params: string[];
43
+ server_script?: string;
44
+ server_script_path?: string;
45
+ has_guard?: boolean;
46
+ has_load?: boolean;
47
+ has_action?: boolean;
48
+ export_paths?: string[];
35
49
  };
package/dist/manifest.js CHANGED
@@ -18,14 +18,23 @@ import { readFileSync } from 'node:fs';
18
18
  import { readdir, stat } from 'node:fs/promises';
19
19
  import { join, relative, sep, basename, extname, dirname } from 'node:path';
20
20
  import { extractServerScript } from './build/server-script.js';
21
+ import { analyzeResourceRouteModule, isResourceRouteFile } from './resource-route-module.js';
21
22
  import { composeServerScriptEnvelope, resolveAdjacentServerModules } from './server-script-composition.js';
23
+ import { validateStaticExportPaths } from './static-export-paths.js';
22
24
  /**
23
25
  * @typedef {{
24
26
  * path: string,
25
27
  * file: string,
28
+ * route_kind?: 'page' | 'resource',
26
29
  * path_kind: 'static' | 'dynamic',
27
30
  * render_mode: 'prerender' | 'server',
28
- * params: string[]
31
+ * params: string[],
32
+ * server_script?: string,
33
+ * server_script_path?: string,
34
+ * has_guard?: boolean,
35
+ * has_load?: boolean,
36
+ * has_action?: boolean,
37
+ * export_paths?: string[]
29
38
  * }} ManifestEntry
30
39
  */
31
40
  /**
@@ -75,17 +84,20 @@ async function _scanDir(dir, root, ext, compilerOpts) {
75
84
  }
76
85
  else if (item.endsWith(ext)) {
77
86
  const routePath = _fileToRoute(fullPath, root, ext);
78
- entries.push(buildManifestEntry({
87
+ entries.push(buildPageManifestEntry({
79
88
  fullPath,
80
89
  root,
81
90
  routePath,
82
91
  compilerOpts
83
92
  }));
84
93
  }
94
+ else if (isResourceRouteFile(item)) {
95
+ entries.push(analyzeResourceRouteModule(fullPath, root));
96
+ }
85
97
  }
86
98
  return entries;
87
99
  }
88
- function buildManifestEntry({ fullPath, root, routePath, compilerOpts }) {
100
+ function buildPageManifestEntry({ fullPath, root, routePath, compilerOpts }) {
89
101
  const rawSource = readFileSync(fullPath, 'utf8');
90
102
  const inlineServerScript = extractServerScript(rawSource, fullPath, compilerOpts).serverScript;
91
103
  const { guardPath, loadPath, actionPath } = resolveAdjacentServerModules(fullPath);
@@ -96,12 +108,17 @@ function buildManifestEntry({ fullPath, root, routePath, compilerOpts }) {
96
108
  adjacentLoadPath: loadPath,
97
109
  adjacentActionPath: actionPath
98
110
  });
111
+ const exportPaths = Array.isArray(composed.serverScript?.export_paths)
112
+ ? validateStaticExportPaths(routePath, composed.serverScript.export_paths, fullPath)
113
+ : [];
99
114
  return {
100
115
  path: routePath,
101
116
  file: relative(root, fullPath),
117
+ route_kind: 'page',
102
118
  path_kind: _isDynamic(routePath) ? 'dynamic' : 'static',
103
119
  render_mode: composed.serverScript && composed.serverScript.prerender !== true ? 'server' : 'prerender',
104
- params: extractRouteParams(routePath)
120
+ params: extractRouteParams(routePath),
121
+ ...(exportPaths.length > 0 ? { export_paths: exportPaths } : {})
105
122
  };
106
123
  }
107
124
  function extractRouteParams(routePath) {
@@ -325,7 +342,9 @@ function segmentWeight(segment) {
325
342
  * @returns {string}
326
343
  */
327
344
  export function serializeManifest(entries) {
328
- const lines = entries.map((e) => {
345
+ const lines = entries
346
+ .filter((entry) => entry?.route_kind !== 'resource')
347
+ .map((e) => {
329
348
  const hasParams = _isDynamic(e.path);
330
349
  const loader = hasParams
331
350
  ? `(params) => import('./pages/${e.file}')`
package/dist/preview.d.ts CHANGED
@@ -37,20 +37,27 @@ export function createPreviewServer(options: {
37
37
  * @returns {Promise<PreviewRoute[]>}
38
38
  */
39
39
  export function loadRouteManifest(distDir: string): Promise<PreviewRoute[]>;
40
+ export function loadRouteSurfaceState(distDir: any, fallbackBasePath?: string): Promise<{
41
+ basePath: string;
42
+ pageRoutes: any;
43
+ resourceRoutes: any[];
44
+ }>;
40
45
  /**
41
- * @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
42
- * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, action: string, load: string }, status?: number }>}
46
+ * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl?: string, requestMethod?: string, requestHeaders?: Record<string, string | string[] | undefined>, requestBodyBuffer?: Buffer | null, routePattern?: string, routeFile?: string, routeId?: string, routeKind?: 'page' | 'resource' }} input
47
+ * @returns {Promise<{ result: { kind: string, [key: string]: unknown }, trace: { guard: string, action: string, load: string }, status?: number, setCookies?: string[] }>}
43
48
  */
44
- export function executeServerRoute({ source, sourcePath, params, requestUrl, requestMethod, requestHeaders, requestBodyBase64, routePattern, routeFile, routeId, guardOnly }: {
49
+ export function executeServerRoute({ source, sourcePath, params, requestUrl, requestMethod, requestHeaders, requestBodyBuffer, routePattern, routeFile, routeId, routeKind, guardOnly }: {
45
50
  source: string;
46
51
  sourcePath: string;
47
52
  params: Record<string, string>;
48
53
  requestUrl?: string;
49
54
  requestMethod?: string;
50
55
  requestHeaders?: Record<string, string | string[] | undefined>;
56
+ requestBodyBuffer?: Buffer | null;
51
57
  routePattern?: string;
52
58
  routeFile?: string;
53
59
  routeId?: string;
60
+ routeKind?: "page" | "resource";
54
61
  }): Promise<{
55
62
  result: {
56
63
  kind: string;
@@ -62,6 +69,7 @@ export function executeServerRoute({ source, sourcePath, params, requestUrl, req
62
69
  load: string;
63
70
  };
64
71
  status?: number;
72
+ setCookies?: string[];
65
73
  }>;
66
74
  /**
67
75
  * @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