@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
@@ -0,0 +1,71 @@
1
+ import { appLocalRedirectLocation } from './base-path.js';
2
+ import { buildAttachmentContentDisposition, decodeDownloadResultBody } from './download-result.js';
3
+ import { clientFacingRouteMessage, defaultRouteDenyMessage } from './server-error.js';
4
+ function serializeJsonBody(payload) {
5
+ return JSON.stringify(payload);
6
+ }
7
+ export function buildResourceResponseDescriptor(result, basePath = '/', setCookies = []) {
8
+ if (!result || typeof result !== 'object') {
9
+ return {
10
+ status: 500,
11
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
12
+ body: defaultRouteDenyMessage(500),
13
+ setCookies
14
+ };
15
+ }
16
+ if (result.kind === 'redirect') {
17
+ return {
18
+ status: Number.isInteger(result.status) ? result.status : 302,
19
+ headers: {
20
+ Location: appLocalRedirectLocation(result.location, basePath),
21
+ 'Cache-Control': 'no-store'
22
+ },
23
+ body: '',
24
+ setCookies
25
+ };
26
+ }
27
+ if (result.kind === 'deny') {
28
+ const status = Number.isInteger(result.status) ? result.status : 403;
29
+ return {
30
+ status,
31
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
32
+ body: clientFacingRouteMessage(status, result.message),
33
+ setCookies
34
+ };
35
+ }
36
+ if (result.kind === 'json') {
37
+ return {
38
+ status: Number.isInteger(result.status) ? result.status : 200,
39
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
40
+ body: serializeJsonBody(result.data),
41
+ setCookies
42
+ };
43
+ }
44
+ if (result.kind === 'text') {
45
+ return {
46
+ status: Number.isInteger(result.status) ? result.status : 200,
47
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
48
+ body: result.body,
49
+ setCookies
50
+ };
51
+ }
52
+ if (result.kind === 'download') {
53
+ const body = decodeDownloadResultBody(result, 'resource download response');
54
+ return {
55
+ status: 200,
56
+ headers: {
57
+ 'Content-Type': result.contentType,
58
+ 'Content-Disposition': buildAttachmentContentDisposition(result.filename),
59
+ 'Content-Length': String(body.byteLength)
60
+ },
61
+ body,
62
+ setCookies
63
+ };
64
+ }
65
+ return {
66
+ status: 500,
67
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
68
+ body: defaultRouteDenyMessage(500),
69
+ setCookies
70
+ };
71
+ }
@@ -0,0 +1,15 @@
1
+ export function isResourceRouteFile(fileName: any): boolean;
2
+ export function resourceRouteFileToRoute(filePath: any, root: any): string;
3
+ export function analyzeResourceRouteModule(fullPath: any, root: any): {
4
+ path: string;
5
+ file: string;
6
+ path_kind: string;
7
+ render_mode: string;
8
+ route_kind: string;
9
+ params: string[];
10
+ server_script: string;
11
+ server_script_path: any;
12
+ has_guard: boolean;
13
+ has_load: boolean;
14
+ has_action: boolean;
15
+ };
@@ -0,0 +1,129 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { relative, sep } from 'node:path';
3
+ const RESOURCE_EXTENSIONS = ['.resource.ts', '.resource.js', '.resource.mts', '.resource.cts', '.resource.mjs', '.resource.cjs'];
4
+ const FORBIDDEN_RESOURCE_EXPORT_RE = /\bexport\s+const\s+(?:data|prerender|exportPaths|ssr_data|props|ssr)\b/;
5
+ function readSingleExport(source, name) {
6
+ const fnMatch = source.match(new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+${name}\\s*\\(([^)]*)\\)`));
7
+ const constParenMatch = source.match(new RegExp(`\\bexport\\s+const\\s+${name}\\s*=\\s*(?:async\\s*)?\\(([^)]*)\\)\\s*=>`));
8
+ const constSingleArgMatch = source.match(new RegExp(`\\bexport\\s+const\\s+${name}\\s*=\\s*(?:async\\s*)?([a-zA-Z_$][a-zA-Z0-9_$]*)\\s*=>`));
9
+ const matchCount = Number(Boolean(fnMatch)) +
10
+ Number(Boolean(constParenMatch)) +
11
+ Number(Boolean(constSingleArgMatch));
12
+ return {
13
+ fnMatch,
14
+ constParenMatch,
15
+ constSingleArgMatch,
16
+ hasExport: matchCount > 0,
17
+ matchCount
18
+ };
19
+ }
20
+ function assertSingleCtxArg(sourceFile, name, exportMatch) {
21
+ const singleArg = String(exportMatch.constSingleArgMatch?.[1] || '').trim();
22
+ const paramsText = String((exportMatch.fnMatch || exportMatch.constParenMatch)?.[1] || '').trim();
23
+ const arity = singleArg ? 1 : paramsText.length === 0 ? 0 : paramsText.split(',').length;
24
+ if (arity !== 1) {
25
+ throw new Error(`Zenith resource route contract violation:\n` +
26
+ ` File: ${sourceFile}\n` +
27
+ ` Reason: ${name}(ctx) must accept exactly one argument\n` +
28
+ ` Example: export async function ${name}(ctx) { ... }`);
29
+ }
30
+ }
31
+ function segmentsToRoute(segments) {
32
+ const routeSegments = segments.map((seg) => {
33
+ const optionalCatchAllMatch = seg.match(/^\[\[\.\.\.([a-zA-Z_][a-zA-Z0-9_]*)\]\]$/);
34
+ if (optionalCatchAllMatch) {
35
+ return `*${optionalCatchAllMatch[1]}?`;
36
+ }
37
+ const catchAllMatch = seg.match(/^\[\.\.\.([a-zA-Z_][a-zA-Z0-9_]*)\]$/);
38
+ if (catchAllMatch) {
39
+ return `*${catchAllMatch[1]}`;
40
+ }
41
+ const paramMatch = seg.match(/^\[([a-zA-Z_][a-zA-Z0-9_]*)\]$/);
42
+ if (paramMatch) {
43
+ return `:${paramMatch[1]}`;
44
+ }
45
+ return seg;
46
+ });
47
+ if (routeSegments.length > 0) {
48
+ const last = routeSegments[routeSegments.length - 1];
49
+ if (last === 'index' || last === 'page') {
50
+ routeSegments.pop();
51
+ }
52
+ }
53
+ return `/${routeSegments.join('/')}`;
54
+ }
55
+ export function isResourceRouteFile(fileName) {
56
+ return RESOURCE_EXTENSIONS.some((extension) => fileName.endsWith(extension));
57
+ }
58
+ export function resourceRouteFileToRoute(filePath, root) {
59
+ const rel = relative(root, filePath);
60
+ const extension = RESOURCE_EXTENSIONS.find((candidate) => rel.endsWith(candidate));
61
+ if (!extension) {
62
+ throw new Error(`[Zenith CLI] Resource route "${filePath}" does not use a supported .resource.* extension.`);
63
+ }
64
+ const withoutExt = rel.slice(0, -extension.length);
65
+ const segments = withoutExt.split(sep).filter(Boolean);
66
+ const route = segmentsToRoute(segments);
67
+ return route === '/' ? '/' : route.replace(/\/+/g, '/');
68
+ }
69
+ export function analyzeResourceRouteModule(fullPath, root) {
70
+ const source = readFileSync(fullPath, 'utf8').trim();
71
+ if (!source) {
72
+ throw new Error(`Zenith resource route contract violation:\n` +
73
+ ` File: ${fullPath}\n` +
74
+ ` Reason: resource route module is empty\n` +
75
+ ` Example: export async function load(ctx) { return ctx.json({ ok: true }); }`);
76
+ }
77
+ if (FORBIDDEN_RESOURCE_EXPORT_RE.test(source)) {
78
+ throw new Error(`Zenith resource route contract violation:\n` +
79
+ ` File: ${fullPath}\n` +
80
+ ` Reason: resource routes may only export guard(ctx), load(ctx), and action(ctx)\n` +
81
+ ` Example: remove page-only exports such as data/prerender/exportPaths`);
82
+ }
83
+ const guardExport = readSingleExport(source, 'guard');
84
+ const loadExport = readSingleExport(source, 'load');
85
+ const actionExport = readSingleExport(source, 'action');
86
+ for (const [name, exportMatch] of [
87
+ ['guard', guardExport],
88
+ ['load', loadExport],
89
+ ['action', actionExport]
90
+ ]) {
91
+ if (exportMatch.matchCount > 1) {
92
+ throw new Error(`Zenith resource route contract violation:\n` +
93
+ ` File: ${fullPath}\n` +
94
+ ` Reason: multiple ${name} exports detected\n` +
95
+ ` Example: keep exactly one export for ${name}(ctx)`);
96
+ }
97
+ if (exportMatch.hasExport) {
98
+ assertSingleCtxArg(fullPath, name, exportMatch);
99
+ }
100
+ }
101
+ if (!loadExport.hasExport && !actionExport.hasExport) {
102
+ throw new Error(`Zenith resource route contract violation:\n` +
103
+ ` File: ${fullPath}\n` +
104
+ ` Reason: resource routes must export load(ctx), action(ctx), or both\n` +
105
+ ` Example: export async function load(ctx) { return ctx.text('ok'); }`);
106
+ }
107
+ return {
108
+ path: resourceRouteFileToRoute(fullPath, root),
109
+ file: relative(root, fullPath).replaceAll('\\', '/'),
110
+ path_kind: resourceRouteFileToRoute(fullPath, root).split('/').some((segment) => segment.startsWith(':') || segment.startsWith('*'))
111
+ ? 'dynamic'
112
+ : 'static',
113
+ render_mode: 'server',
114
+ route_kind: 'resource',
115
+ params: resourceRouteFileToRoute(fullPath, root)
116
+ .split('/')
117
+ .filter(Boolean)
118
+ .filter((segment) => segment.startsWith(':') || segment.startsWith('*'))
119
+ .map((segment) => {
120
+ const raw = segment.slice(1);
121
+ return raw.endsWith('?') ? raw.slice(0, -1) : raw;
122
+ }),
123
+ server_script: source,
124
+ server_script_path: fullPath,
125
+ has_guard: guardExport.hasExport,
126
+ has_load: loadExport.hasExport,
127
+ has_action: actionExport.hasExport
128
+ };
129
+ }
@@ -1,4 +1,4 @@
1
- const ROUTE_CHECK_UNSUPPORTED_TARGETS = new Set(['vercel', 'netlify']);
1
+ const ROUTE_CHECK_UNSUPPORTED_TARGETS = new Set(['vercel', 'netlify', 'static-export']);
2
2
  export function supportsTargetRouteCheck(target) {
3
3
  return !ROUTE_CHECK_UNSUPPORTED_TARGETS.has(String(target || '').trim());
4
4
  }
@@ -20,32 +20,40 @@ export function invalid(payload: any, status?: number): {
20
20
  data: any;
21
21
  status: number;
22
22
  };
23
- export function validateServerExports({ exports, filePath }: {
23
+ export function json(payload: any, status?: number): {
24
+ kind: string;
25
+ data: any;
26
+ status: number;
27
+ };
28
+ export function text(body: any, status?: number): {
29
+ kind: string;
30
+ body: string;
31
+ status: number;
32
+ };
33
+ export function download(body: any, options?: {}): {
34
+ kind: string;
35
+ body: any;
36
+ bodyEncoding: string;
37
+ bodySize: number;
38
+ filename: string;
39
+ contentType: string;
40
+ status: number;
41
+ };
42
+ export function validateServerExports({ exports, filePath, routeKind }: {
24
43
  exports: any;
25
44
  filePath: any;
45
+ routeKind?: string | undefined;
26
46
  }): void;
27
47
  export function assertJsonSerializable(value: any, where?: string): void;
28
- export function resolveRouteResult({ exports, ctx, filePath, guardOnly }: {
48
+ export function resolveRouteResult({ exports, ctx, filePath, guardOnly, routeKind }: {
29
49
  exports: any;
30
50
  ctx: any;
31
51
  filePath: any;
32
52
  guardOnly?: boolean | undefined;
53
+ routeKind?: string | undefined;
33
54
  }): Promise<{
34
55
  result: any;
35
- trace: {
36
- guard: string;
37
- action: string;
38
- load: string;
39
- };
40
- status?: undefined;
41
- } | {
42
- result: any;
43
- trace: {
44
- guard: string;
45
- action: string;
46
- load: string;
47
- };
48
- status: number | undefined;
56
+ trace: any;
49
57
  }>;
50
58
  export function resolveServerPayload({ exports, ctx, filePath }: {
51
59
  exports: any;
@@ -1,10 +1,12 @@
1
1
  // server-contract.js — Zenith CLI V0
2
2
  // ---------------------------------------------------------------------------
3
3
  // Shared validation and payload resolution logic for <script server> blocks.
4
- const NEW_KEYS = new Set(['data', 'load', 'guard', 'action', 'prerender']);
5
- const LEGACY_KEYS = new Set(['ssr_data', 'props', 'ssr', 'prerender']);
6
- const ALLOWED_KEYS = new Set(['data', 'load', 'guard', 'action', 'prerender', 'ssr_data', 'props', 'ssr']);
7
- const ROUTE_RESULT_KINDS = new Set(['allow', 'redirect', 'deny', 'data', 'invalid']);
4
+ import { assertValidDownloadResult, createDownloadResult } from './download-result.js';
5
+ const ALLOWED_KEYS = new Set(['data', 'load', 'guard', 'action', 'prerender', 'exportPaths', 'ssr_data', 'props', 'ssr']);
6
+ const RESOURCE_ALLOWED_KEYS = new Set(['load', 'guard', 'action']);
7
+ const ROUTE_RESULT_KINDS = new Set(['allow', 'redirect', 'deny', 'data', 'invalid', 'json', 'text', 'download']);
8
+ const AUTH_CONTROL_FLOW_FLAG = '__zenith_auth_control_flow';
9
+ const STAGED_SET_COOKIES_KEY = '__zenith_staged_set_cookies';
8
10
  export function allow() {
9
11
  return { kind: 'allow' };
10
12
  }
@@ -32,6 +34,23 @@ export function invalid(payload, status = 400) {
32
34
  status: Number.isInteger(status) ? status : 400
33
35
  };
34
36
  }
37
+ export function json(payload, status = 200) {
38
+ return {
39
+ kind: 'json',
40
+ data: payload,
41
+ status: Number.isInteger(status) ? status : 200
42
+ };
43
+ }
44
+ export function text(body, status = 200) {
45
+ return {
46
+ kind: 'text',
47
+ body: typeof body === 'string' ? body : String(body ?? ''),
48
+ status: Number.isInteger(status) ? status : 200
49
+ };
50
+ }
51
+ export function download(body, options = {}) {
52
+ return createDownloadResult(body, options);
53
+ }
35
54
  function isRouteResultLike(value) {
36
55
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
37
56
  return false;
@@ -69,6 +88,17 @@ function assertValidRouteResultShape(value, where, allowedKinds) {
69
88
  throw new Error(`[Zenith] ${where}: invalid status must be 400 or 422.`);
70
89
  }
71
90
  }
91
+ if (kind === 'json' || kind === 'text') {
92
+ if (!Number.isInteger(value.status) || value.status < 200 || value.status > 599 || (value.status >= 300 && value.status <= 399)) {
93
+ throw new Error(`[Zenith] ${where}: ${kind} status must be an integer between 200-599 and may not be 3xx.`);
94
+ }
95
+ if (kind === 'text' && typeof value.body !== 'string') {
96
+ throw new Error(`[Zenith] ${where}: text body must be a string.`);
97
+ }
98
+ }
99
+ if (kind === 'download') {
100
+ assertValidDownloadResult(value, where);
101
+ }
72
102
  }
73
103
  function assertOneArgRouteFunction({ filePath, exportName, value }) {
74
104
  if (typeof value !== 'function') {
@@ -103,9 +133,43 @@ function buildActionState(result) {
103
133
  }
104
134
  return null;
105
135
  }
106
- export function validateServerExports({ exports, filePath }) {
136
+ function unwrapAuthControlFlow(error, where, allowedKinds) {
137
+ if (!error || typeof error !== 'object' || error[AUTH_CONTROL_FLOW_FLAG] !== true) {
138
+ return null;
139
+ }
140
+ const result = error.result;
141
+ assertValidRouteResultShape(result, where, allowedKinds);
142
+ return result;
143
+ }
144
+ async function invokeRouteStage({ fn, ctx, where, allowedKinds }) {
145
+ try {
146
+ return await fn(ctx);
147
+ }
148
+ catch (error) {
149
+ const authResult = unwrapAuthControlFlow(error, where, allowedKinds);
150
+ if (authResult) {
151
+ return authResult;
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+ function buildResolvedEnvelope({ result, trace, status, ctx }) {
157
+ const envelope = { result, trace };
158
+ if (status !== undefined) {
159
+ envelope.status = status;
160
+ }
161
+ const setCookies = Array.isArray(ctx?.[STAGED_SET_COOKIES_KEY])
162
+ ? ctx[STAGED_SET_COOKIES_KEY].slice()
163
+ : [];
164
+ if (setCookies.length > 0) {
165
+ envelope.setCookies = setCookies;
166
+ }
167
+ return envelope;
168
+ }
169
+ export function validateServerExports({ exports, filePath, routeKind = 'page' }) {
107
170
  const exportKeys = Object.keys(exports);
108
- const illegalKeys = exportKeys.filter(k => !ALLOWED_KEYS.has(k));
171
+ const allowedKeys = routeKind === 'resource' ? RESOURCE_ALLOWED_KEYS : ALLOWED_KEYS;
172
+ const illegalKeys = exportKeys.filter(k => !allowedKeys.has(k));
109
173
  if (illegalKeys.length > 0) {
110
174
  throw new Error(`[Zenith] ${filePath}: illegal export(s): ${illegalKeys.join(', ')}`);
111
175
  }
@@ -115,15 +179,28 @@ export function validateServerExports({ exports, filePath }) {
115
179
  const hasAction = 'action' in exports;
116
180
  const hasNew = hasData || hasLoad || hasAction;
117
181
  const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
182
+ if (routeKind === 'resource') {
183
+ if (hasData) {
184
+ throw new Error(`[Zenith] ${filePath}: resource routes may not export "data". Use load(ctx) or action(ctx) with ctx.json()/ctx.text().`);
185
+ }
186
+ if (!hasLoad && !hasAction) {
187
+ throw new Error(`[Zenith] ${filePath}: resource routes must export load(ctx), action(ctx), or both.`);
188
+ }
189
+ }
118
190
  if (hasData && hasLoad) {
119
191
  throw new Error(`[Zenith] ${filePath}: cannot export both "data" and "load". Choose one.`);
120
192
  }
121
- if (hasNew && hasLegacy) {
193
+ if (routeKind === 'page' && hasNew && hasLegacy) {
122
194
  throw new Error(`[Zenith] ${filePath}: cannot mix new ("data"/"load") with legacy ("ssr_data"/"props"/"ssr") exports.`);
123
195
  }
124
- if ('prerender' in exports && typeof exports.prerender !== 'boolean') {
196
+ if (routeKind === 'page' && 'prerender' in exports && typeof exports.prerender !== 'boolean') {
125
197
  throw new Error(`[Zenith] ${filePath}: "prerender" must be a boolean.`);
126
198
  }
199
+ if (routeKind === 'page' && 'exportPaths' in exports) {
200
+ if (!Array.isArray(exports.exportPaths) || exports.exportPaths.some((value) => typeof value !== 'string')) {
201
+ throw new Error(`[Zenith] ${filePath}: "exportPaths" must be an array of string pathnames.`);
202
+ }
203
+ }
127
204
  if (hasLoad) {
128
205
  assertOneArgRouteFunction({ filePath, exportName: 'load', value: exports.load });
129
206
  }
@@ -185,8 +262,11 @@ export function assertJsonSerializable(value, where = 'payload') {
185
262
  }
186
263
  walk(value, '$');
187
264
  }
188
- export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = false }) {
189
- validateServerExports({ exports, filePath });
265
+ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = false, routeKind = 'page' }) {
266
+ validateServerExports({ exports, filePath, routeKind });
267
+ if (routeKind === 'resource') {
268
+ return resolveResourceRouteResult({ exports, ctx, filePath, guardOnly });
269
+ }
190
270
  const trace = {
191
271
  guard: 'none',
192
272
  action: 'none',
@@ -199,7 +279,12 @@ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = f
199
279
  ctx.action = null;
200
280
  }
201
281
  if ('guard' in exports) {
202
- const guardRaw = await exports.guard(ctx);
282
+ const guardRaw = await invokeRouteStage({
283
+ fn: exports.guard,
284
+ ctx,
285
+ where: `${filePath}: guard(ctx)`,
286
+ allowedKinds: new Set(['allow', 'redirect', 'deny'])
287
+ });
203
288
  const guardResult = guardRaw == null ? allow() : guardRaw;
204
289
  if (guardResult.kind === 'data') {
205
290
  throw new Error(`[Zenith] ${filePath}: guard(ctx) returned data(payload) which is a critical invariant violation. guard() can only return allow(), redirect(), or deny(). Use load(ctx) for data injection.`);
@@ -207,14 +292,19 @@ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = f
207
292
  assertValidRouteResultShape(guardResult, `${filePath}: guard(ctx) return`, new Set(['allow', 'redirect', 'deny']));
208
293
  trace.guard = guardResult.kind;
209
294
  if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
210
- return { result: guardResult, trace };
295
+ return buildResolvedEnvelope({ result: guardResult, trace, ctx });
211
296
  }
212
297
  }
213
298
  if (guardOnly) {
214
- return { result: allow(), trace };
299
+ return buildResolvedEnvelope({ result: allow(), trace, ctx });
215
300
  }
216
301
  if (isActionRequest && 'action' in exports) {
217
- const actionRaw = await exports.action(ctx);
302
+ const actionRaw = await invokeRouteStage({
303
+ fn: exports.action,
304
+ ctx,
305
+ where: `${filePath}: action(ctx)`,
306
+ allowedKinds: new Set(['data', 'invalid', 'redirect', 'deny'])
307
+ });
218
308
  let actionResult = null;
219
309
  if (isRouteResultLike(actionRaw)) {
220
310
  actionResult = actionRaw;
@@ -229,7 +319,7 @@ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = f
229
319
  }
230
320
  trace.action = actionResult.kind;
231
321
  if (actionResult.kind === 'redirect' || actionResult.kind === 'deny') {
232
- return { result: actionResult, trace };
322
+ return buildResolvedEnvelope({ result: actionResult, trace, ctx });
233
323
  }
234
324
  const actionState = buildActionState(actionResult);
235
325
  if (ctx && typeof ctx === 'object') {
@@ -241,7 +331,12 @@ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = f
241
331
  }
242
332
  let payload;
243
333
  if ('load' in exports) {
244
- const loadRaw = await exports.load(ctx);
334
+ const loadRaw = await invokeRouteStage({
335
+ fn: exports.load,
336
+ ctx,
337
+ where: `${filePath}: load(ctx)`,
338
+ allowedKinds: new Set(['data', 'redirect', 'deny'])
339
+ });
245
340
  let loadResult = null;
246
341
  if (isRouteResultLike(loadRaw)) {
247
342
  loadResult = loadRaw;
@@ -252,42 +347,139 @@ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = f
252
347
  loadResult = data(loadRaw);
253
348
  }
254
349
  trace.load = loadResult.kind;
255
- return { result: loadResult, trace, status: loadResult.kind === 'data' ? responseStatus : undefined };
350
+ return buildResolvedEnvelope({
351
+ result: loadResult,
352
+ trace,
353
+ status: loadResult.kind === 'data' ? responseStatus : undefined,
354
+ ctx
355
+ });
256
356
  }
257
357
  if ('data' in exports) {
258
358
  payload = exports.data;
259
359
  assertJsonSerializable(payload, `${filePath}: data export`);
260
360
  trace.load = 'data';
261
- return { result: data(payload), trace, status: responseStatus };
361
+ return buildResolvedEnvelope({ result: data(payload), trace, status: responseStatus, ctx });
262
362
  }
263
363
  // legacy fallback
264
364
  if ('ssr_data' in exports) {
265
365
  payload = exports.ssr_data;
266
366
  assertJsonSerializable(payload, `${filePath}: ssr_data export`);
267
367
  trace.load = 'data';
268
- return { result: data(payload), trace, status: responseStatus };
368
+ return buildResolvedEnvelope({ result: data(payload), trace, status: responseStatus, ctx });
269
369
  }
270
370
  if ('props' in exports) {
271
371
  payload = exports.props;
272
372
  assertJsonSerializable(payload, `${filePath}: props export`);
273
373
  trace.load = 'data';
274
- return { result: data(payload), trace, status: responseStatus };
374
+ return buildResolvedEnvelope({ result: data(payload), trace, status: responseStatus, ctx });
275
375
  }
276
376
  if ('ssr' in exports) {
277
377
  payload = exports.ssr;
278
378
  assertJsonSerializable(payload, `${filePath}: ssr export`);
279
379
  trace.load = 'data';
280
- return { result: data(payload), trace, status: responseStatus };
380
+ return buildResolvedEnvelope({ result: data(payload), trace, status: responseStatus, ctx });
281
381
  }
282
382
  if (isActionRequest && ctx?.action) {
283
383
  trace.load = 'data';
284
- return {
384
+ return buildResolvedEnvelope({
285
385
  result: data({ action: ctx.action }),
286
386
  trace,
287
- status: responseStatus
288
- };
387
+ status: responseStatus,
388
+ ctx
389
+ });
390
+ }
391
+ return buildResolvedEnvelope({ result: data({}), trace, status: responseStatus, ctx });
392
+ }
393
+ async function resolveResourceRouteResult({ exports, ctx, filePath, guardOnly = false }) {
394
+ const trace = {
395
+ guard: 'none',
396
+ action: 'none',
397
+ load: 'none'
398
+ };
399
+ const requestMethod = String(ctx?.method || ctx?.request?.method || 'GET').toUpperCase();
400
+ if (ctx && typeof ctx === 'object') {
401
+ ctx.action = null;
289
402
  }
290
- return { result: data({}), trace, status: responseStatus };
403
+ if ('guard' in exports) {
404
+ const guardRaw = await invokeRouteStage({
405
+ fn: exports.guard,
406
+ ctx,
407
+ where: `${filePath}: guard(ctx)`,
408
+ allowedKinds: new Set(['allow', 'redirect', 'deny'])
409
+ });
410
+ const guardResult = guardRaw == null ? allow() : guardRaw;
411
+ assertValidRouteResultShape(guardResult, `${filePath}: guard(ctx) return`, new Set(['allow', 'redirect', 'deny']));
412
+ trace.guard = guardResult.kind;
413
+ if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
414
+ return buildResolvedEnvelope({ result: guardResult, trace, ctx });
415
+ }
416
+ }
417
+ if (guardOnly) {
418
+ return buildResolvedEnvelope({ result: allow(), trace, ctx });
419
+ }
420
+ if (requestMethod === 'GET' || requestMethod === 'HEAD') {
421
+ if (!('load' in exports)) {
422
+ trace.load = 'text';
423
+ return buildResolvedEnvelope({ result: text('Method Not Allowed', 405), trace, status: 405, ctx });
424
+ }
425
+ const loadResult = await resolveResourceStage({
426
+ exports,
427
+ exportName: 'load',
428
+ ctx,
429
+ filePath,
430
+ trace,
431
+ traceKey: 'load'
432
+ });
433
+ return buildResolvedEnvelope({
434
+ result: loadResult,
435
+ trace,
436
+ status: loadResult.status,
437
+ ctx
438
+ });
439
+ }
440
+ if (requestMethod === 'POST') {
441
+ if (!('action' in exports)) {
442
+ trace.action = 'text';
443
+ return buildResolvedEnvelope({ result: text('Method Not Allowed', 405), trace, status: 405, ctx });
444
+ }
445
+ const actionResult = await resolveResourceStage({
446
+ exports,
447
+ exportName: 'action',
448
+ ctx,
449
+ filePath,
450
+ trace,
451
+ traceKey: 'action'
452
+ });
453
+ return buildResolvedEnvelope({
454
+ result: actionResult,
455
+ trace,
456
+ status: actionResult.status,
457
+ ctx
458
+ });
459
+ }
460
+ return buildResolvedEnvelope({
461
+ result: text('Method Not Allowed', 405),
462
+ trace,
463
+ status: 405,
464
+ ctx
465
+ });
466
+ }
467
+ async function resolveResourceStage({ exports, exportName, ctx, filePath, trace, traceKey }) {
468
+ const raw = await invokeRouteStage({
469
+ fn: exports[exportName],
470
+ ctx,
471
+ where: `${filePath}: ${exportName}(ctx)`,
472
+ allowedKinds: new Set(['json', 'text', 'download', 'redirect', 'deny'])
473
+ });
474
+ if (!isRouteResultLike(raw)) {
475
+ throw new Error(`[Zenith] ${filePath}: ${exportName}(ctx) on a resource route must return json(...), text(...), download(...), redirect(...), or deny(...).`);
476
+ }
477
+ assertValidRouteResultShape(raw, `${filePath}: ${exportName}(ctx) return`, new Set(['json', 'text', 'download', 'redirect', 'deny']));
478
+ if (raw.kind === 'json') {
479
+ assertJsonSerializable(raw.data, `${filePath}: ${exportName}(ctx) return`);
480
+ }
481
+ trace[traceKey] = raw.kind;
482
+ return raw;
291
483
  }
292
484
  export async function resolveServerPayload({ exports, ctx, filePath }) {
293
485
  const resolved = await resolveRouteResult({ exports, ctx, filePath });
@@ -1,4 +1,4 @@
1
- export function defaultRouteDenyMessage(status: any): "Unauthorized" | "Forbidden" | "Not Found" | "Internal Server Error";
1
+ export function defaultRouteDenyMessage(status: any): "Unauthorized" | "Forbidden" | "Not Found" | "Method Not Allowed" | "Internal Server Error";
2
2
  export function clientFacingRouteMessage(status: any, message: any): string;
3
3
  export function sanitizeRouteResult(result: any): any;
4
4
  export function logServerException(scope: any, error: any): void;
@@ -5,6 +5,8 @@ export function defaultRouteDenyMessage(status) {
5
5
  return 'Forbidden';
6
6
  if (status === 404)
7
7
  return 'Not Found';
8
+ if (status === 405)
9
+ return 'Method Not Allowed';
8
10
  return 'Internal Server Error';
9
11
  }
10
12
  export function clientFacingRouteMessage(status, message) {
@@ -9,6 +9,7 @@ export function writeServerOutput({ coreOutputDir, staticDir, projectRoot, confi
9
9
  routes: {
10
10
  name: any;
11
11
  path: any;
12
+ route_kind: any;
12
13
  output: any;
13
14
  base_path: string;
14
15
  page_asset: any;
@@ -21,7 +22,7 @@ export function writeServerOutput({ coreOutputDir, staticDir, projectRoot, confi
21
22
  has_guard: boolean;
22
23
  has_load: boolean;
23
24
  has_action: boolean;
24
- params: string[];
25
+ params: any[];
25
26
  image_manifest_file: string | null;
26
27
  image_config: any;
27
28
  }[];