@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.
- package/README.md +5 -3
- package/dist/adapters/adapter-netlify.d.ts +1 -1
- package/dist/adapters/adapter-netlify.js +56 -14
- package/dist/adapters/adapter-static-export.d.ts +5 -0
- package/dist/adapters/adapter-static-export.js +115 -0
- package/dist/adapters/adapter-types.d.ts +3 -1
- package/dist/adapters/adapter-types.js +5 -2
- package/dist/adapters/adapter-vercel.d.ts +1 -1
- package/dist/adapters/adapter-vercel.js +70 -14
- package/dist/adapters/copy-hosted-page-runtime.d.ts +1 -0
- package/dist/adapters/copy-hosted-page-runtime.js +49 -0
- package/dist/adapters/resolve-adapter.js +4 -0
- package/dist/adapters/route-rules.d.ts +5 -0
- package/dist/adapters/route-rules.js +9 -0
- package/dist/adapters/validate-hosted-resource-routes.d.ts +1 -0
- package/dist/adapters/validate-hosted-resource-routes.js +13 -0
- package/dist/auth/route-auth.d.ts +6 -0
- package/dist/auth/route-auth.js +236 -0
- package/dist/build/compiler-runtime.d.ts +1 -1
- package/dist/build/compiler-runtime.js +8 -2
- package/dist/build/page-loop-state.js +1 -1
- package/dist/build/server-script.d.ts +2 -1
- package/dist/build/server-script.js +7 -3
- package/dist/build-output-manifest.d.ts +3 -2
- package/dist/build-output-manifest.js +3 -0
- package/dist/build.js +29 -17
- package/dist/dev-server.js +79 -25
- package/dist/download-result.d.ts +14 -0
- package/dist/download-result.js +148 -0
- package/dist/images/service.d.ts +13 -1
- package/dist/images/service.js +45 -15
- package/dist/manifest.d.ts +15 -1
- package/dist/manifest.js +24 -5
- package/dist/preview.d.ts +11 -3
- package/dist/preview.js +188 -62
- package/dist/request-body.d.ts +0 -1
- package/dist/request-body.js +0 -6
- package/dist/resource-manifest.d.ts +16 -0
- package/dist/resource-manifest.js +53 -0
- package/dist/resource-response.d.ts +34 -0
- package/dist/resource-response.js +71 -0
- package/dist/resource-route-module.d.ts +15 -0
- package/dist/resource-route-module.js +129 -0
- package/dist/route-check-support.js +1 -1
- package/dist/server-contract.d.ts +24 -16
- package/dist/server-contract.js +217 -25
- package/dist/server-error.d.ts +1 -1
- package/dist/server-error.js +2 -0
- package/dist/server-output.d.ts +2 -1
- package/dist/server-output.js +59 -11
- package/dist/server-runtime/node-server.js +34 -4
- package/dist/server-runtime/route-render.d.ts +25 -1
- package/dist/server-runtime/route-render.js +81 -29
- package/dist/server-script-composition.d.ts +4 -2
- package/dist/server-script-composition.js +6 -3
- package/dist/static-export-paths.d.ts +3 -0
- package/dist/static-export-paths.js +160 -0
- 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
|
|
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;
|
package/dist/server-contract.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
const
|
|
6
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 });
|
package/dist/server-error.d.ts
CHANGED
|
@@ -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;
|
package/dist/server-error.js
CHANGED
|
@@ -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) {
|
package/dist/server-output.d.ts
CHANGED
|
@@ -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:
|
|
25
|
+
params: any[];
|
|
25
26
|
image_manifest_file: string | null;
|
|
26
27
|
image_config: any;
|
|
27
28
|
}[];
|