@zenithbuild/cli 0.7.5 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/adapter-netlify.js +0 -8
- package/dist/adapters/adapter-vercel.js +6 -14
- package/dist/adapters/copy-hosted-page-runtime.js +2 -1
- package/dist/build/hoisted-code-transforms.d.ts +4 -1
- package/dist/build/hoisted-code-transforms.js +5 -3
- package/dist/build/page-ir-normalization.d.ts +1 -1
- package/dist/build/page-ir-normalization.js +33 -3
- package/dist/build/page-loop.js +46 -2
- package/dist/dev-build-session/helpers.d.ts +29 -0
- package/dist/dev-build-session/helpers.js +223 -0
- package/dist/dev-build-session/session.d.ts +24 -0
- package/dist/dev-build-session/session.js +204 -0
- package/dist/dev-build-session/state.d.ts +37 -0
- package/dist/dev-build-session/state.js +17 -0
- package/dist/dev-build-session.d.ts +1 -24
- package/dist/dev-build-session.js +1 -434
- package/dist/dev-server/css-state.d.ts +7 -0
- package/dist/dev-server/css-state.js +92 -0
- package/dist/dev-server/not-found.d.ts +23 -0
- package/dist/dev-server/not-found.js +129 -0
- package/dist/dev-server/request-handler.d.ts +1 -0
- package/dist/dev-server/request-handler.js +376 -0
- package/dist/dev-server/route-check.d.ts +9 -0
- package/dist/dev-server/route-check.js +100 -0
- package/dist/dev-server/watcher.d.ts +5 -0
- package/dist/dev-server/watcher.js +216 -0
- package/dist/dev-server.js +123 -924
- package/dist/images/payload.js +4 -0
- package/dist/manifest.js +46 -1
- package/dist/preview/create-preview-server.d.ts +18 -0
- package/dist/preview/create-preview-server.js +71 -0
- package/dist/preview/manifest.d.ts +42 -0
- package/dist/preview/manifest.js +57 -0
- package/dist/preview/paths.d.ts +3 -0
- package/dist/preview/paths.js +38 -0
- package/dist/preview/payload.d.ts +6 -0
- package/dist/preview/payload.js +34 -0
- package/dist/preview/request-handler.d.ts +1 -0
- package/dist/preview/request-handler.js +300 -0
- package/dist/preview/server-runner.d.ts +49 -0
- package/dist/preview/server-runner.js +220 -0
- package/dist/preview/server-script-runner-template.d.ts +1 -0
- package/dist/preview/server-script-runner-template.js +425 -0
- package/dist/preview.d.ts +5 -112
- package/dist/preview.js +7 -1119
- package/dist/resource-response.d.ts +15 -0
- package/dist/resource-response.js +91 -2
- package/dist/server-contract/constants.d.ts +5 -0
- package/dist/server-contract/constants.js +5 -0
- package/dist/server-contract/export-validation.d.ts +5 -0
- package/dist/server-contract/export-validation.js +59 -0
- package/dist/server-contract/json-serializable.d.ts +1 -0
- package/dist/server-contract/json-serializable.js +52 -0
- package/dist/server-contract/resolve.d.ts +15 -0
- package/dist/server-contract/resolve.js +271 -0
- package/dist/server-contract/result-helpers.d.ts +51 -0
- package/dist/server-contract/result-helpers.js +59 -0
- package/dist/server-contract/route-result-validation.d.ts +2 -0
- package/dist/server-contract/route-result-validation.js +73 -0
- package/dist/server-contract/stage.d.ts +6 -0
- package/dist/server-contract/stage.js +22 -0
- package/dist/server-contract.d.ts +6 -62
- package/dist/server-contract.js +9 -493
- package/dist/server-middleware.d.ts +10 -0
- package/dist/server-middleware.js +30 -0
- package/dist/server-output.js +13 -1
- package/dist/server-runtime/node-server.js +25 -3
- package/package.json +3 -3
|
@@ -6,6 +6,7 @@ export function buildResourceResponseDescriptor(result: any, basePath?: string,
|
|
|
6
6
|
'Content-Type'?: undefined;
|
|
7
7
|
'Content-Disposition'?: undefined;
|
|
8
8
|
'Content-Length'?: undefined;
|
|
9
|
+
Connection?: undefined;
|
|
9
10
|
};
|
|
10
11
|
body: string;
|
|
11
12
|
setCookies: any[];
|
|
@@ -17,6 +18,7 @@ export function buildResourceResponseDescriptor(result: any, basePath?: string,
|
|
|
17
18
|
'Cache-Control'?: undefined;
|
|
18
19
|
'Content-Disposition'?: undefined;
|
|
19
20
|
'Content-Length'?: undefined;
|
|
21
|
+
Connection?: undefined;
|
|
20
22
|
};
|
|
21
23
|
body: any;
|
|
22
24
|
setCookies: any[];
|
|
@@ -28,7 +30,20 @@ export function buildResourceResponseDescriptor(result: any, basePath?: string,
|
|
|
28
30
|
'Content-Length': string;
|
|
29
31
|
Location?: undefined;
|
|
30
32
|
'Cache-Control'?: undefined;
|
|
33
|
+
Connection?: undefined;
|
|
31
34
|
};
|
|
32
35
|
body: Buffer<ArrayBuffer>;
|
|
33
36
|
setCookies: any[];
|
|
37
|
+
} | {
|
|
38
|
+
status: any;
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': any;
|
|
41
|
+
'Cache-Control': string;
|
|
42
|
+
Connection: string;
|
|
43
|
+
Location?: undefined;
|
|
44
|
+
'Content-Disposition'?: undefined;
|
|
45
|
+
'Content-Length'?: undefined;
|
|
46
|
+
};
|
|
47
|
+
body: any;
|
|
48
|
+
setCookies: any[];
|
|
34
49
|
};
|
|
@@ -4,6 +4,67 @@ import { clientFacingRouteMessage, defaultRouteDenyMessage } from './server-erro
|
|
|
4
4
|
function serializeJsonBody(payload) {
|
|
5
5
|
return JSON.stringify(payload);
|
|
6
6
|
}
|
|
7
|
+
function createReadableStreamFromAsyncIterable(iterable) {
|
|
8
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
9
|
+
return new ReadableStream({
|
|
10
|
+
async pull(controller) {
|
|
11
|
+
try {
|
|
12
|
+
const { value, done } = await iterator.next();
|
|
13
|
+
if (done) {
|
|
14
|
+
controller.close();
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
controller.enqueue(typeof value === 'string' ? new TextEncoder().encode(value) : value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
controller.error(err);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
async cancel() {
|
|
25
|
+
if (typeof iterator.return === 'function') {
|
|
26
|
+
await iterator.return();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function createSseStream(events) {
|
|
32
|
+
const iterator = events[Symbol.asyncIterator]();
|
|
33
|
+
const encoder = new TextEncoder();
|
|
34
|
+
return new ReadableStream({
|
|
35
|
+
async pull(controller) {
|
|
36
|
+
try {
|
|
37
|
+
const { value, done } = await iterator.next();
|
|
38
|
+
if (done) {
|
|
39
|
+
controller.close();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let chunk = '';
|
|
43
|
+
if (value.event)
|
|
44
|
+
chunk += `event: ${value.event}\n`;
|
|
45
|
+
if (value.id)
|
|
46
|
+
chunk += `id: ${value.id}\n`;
|
|
47
|
+
if (value.retry)
|
|
48
|
+
chunk += `retry: ${value.retry}\n`;
|
|
49
|
+
const data = typeof value.data === 'string' ? value.data : JSON.stringify(value.data);
|
|
50
|
+
const lines = data.split('\n');
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
chunk += `data: ${line}\n`;
|
|
53
|
+
}
|
|
54
|
+
chunk += '\n';
|
|
55
|
+
controller.enqueue(encoder.encode(chunk));
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
controller.error(err);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
async cancel() {
|
|
62
|
+
if (typeof iterator.return === 'function') {
|
|
63
|
+
await iterator.return();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
7
68
|
export function buildResourceResponseDescriptor(result, basePath = '/', setCookies = []) {
|
|
8
69
|
if (!result || typeof result !== 'object') {
|
|
9
70
|
return {
|
|
@@ -33,9 +94,10 @@ export function buildResourceResponseDescriptor(result, basePath = '/', setCooki
|
|
|
33
94
|
setCookies
|
|
34
95
|
};
|
|
35
96
|
}
|
|
36
|
-
if (result.kind === 'json') {
|
|
97
|
+
if (result.kind === 'json' || result.kind === 'invalid') {
|
|
98
|
+
const status = Number.isInteger(result.status) ? result.status : (result.kind === 'invalid' ? 400 : 200);
|
|
37
99
|
return {
|
|
38
|
-
status
|
|
100
|
+
status,
|
|
39
101
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
40
102
|
body: serializeJsonBody(result.data),
|
|
41
103
|
setCookies
|
|
@@ -62,6 +124,33 @@ export function buildResourceResponseDescriptor(result, basePath = '/', setCooki
|
|
|
62
124
|
setCookies
|
|
63
125
|
};
|
|
64
126
|
}
|
|
127
|
+
if (result.kind === 'stream') {
|
|
128
|
+
const body = typeof result.body?.getReader === 'function'
|
|
129
|
+
? result.body
|
|
130
|
+
: createReadableStreamFromAsyncIterable(result.body);
|
|
131
|
+
return {
|
|
132
|
+
status: Number.isInteger(result.status) ? result.status : 200,
|
|
133
|
+
headers: {
|
|
134
|
+
'Content-Type': result.contentType || 'application/octet-stream',
|
|
135
|
+
'Cache-Control': 'no-cache',
|
|
136
|
+
'Connection': 'keep-alive'
|
|
137
|
+
},
|
|
138
|
+
body,
|
|
139
|
+
setCookies
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (result.kind === 'sse') {
|
|
143
|
+
return {
|
|
144
|
+
status: 200,
|
|
145
|
+
headers: {
|
|
146
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
147
|
+
'Cache-Control': 'no-cache',
|
|
148
|
+
'Connection': 'keep-alive'
|
|
149
|
+
},
|
|
150
|
+
body: createSseStream(result.events),
|
|
151
|
+
setCookies
|
|
152
|
+
};
|
|
153
|
+
}
|
|
65
154
|
return {
|
|
66
155
|
status: 500,
|
|
67
156
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const ALLOWED_KEYS: Set<string>;
|
|
2
|
+
export const RESOURCE_ALLOWED_KEYS: Set<string>;
|
|
3
|
+
export const ROUTE_RESULT_KINDS: Set<string>;
|
|
4
|
+
export const AUTH_CONTROL_FLOW_FLAG: "__zenith_auth_control_flow";
|
|
5
|
+
export const STAGED_SET_COOKIES_KEY: "__zenith_staged_set_cookies";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const ALLOWED_KEYS = new Set(['data', 'load', 'guard', 'action', 'prerender', 'exportPaths', 'ssr_data', 'props', 'ssr']);
|
|
2
|
+
export const RESOURCE_ALLOWED_KEYS = new Set(['load', 'guard', 'action']);
|
|
3
|
+
export const ROUTE_RESULT_KINDS = new Set(['allow', 'redirect', 'deny', 'data', 'invalid', 'json', 'text', 'download', 'stream', 'sse']);
|
|
4
|
+
export const AUTH_CONTROL_FLOW_FLAG = '__zenith_auth_control_flow';
|
|
5
|
+
export const STAGED_SET_COOKIES_KEY = '__zenith_staged_set_cookies';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ALLOWED_KEYS, RESOURCE_ALLOWED_KEYS } from './constants.js';
|
|
2
|
+
function assertOneArgRouteFunction({ filePath, exportName, value }) {
|
|
3
|
+
if (typeof value !== 'function') {
|
|
4
|
+
throw new Error(`[Zenith] ${filePath}: "${exportName}" must be a function.`);
|
|
5
|
+
}
|
|
6
|
+
if (value.length !== 1) {
|
|
7
|
+
throw new Error(`[Zenith] ${filePath}: "${exportName}(ctx)" must take exactly 1 argument.`);
|
|
8
|
+
}
|
|
9
|
+
const fnStr = value.toString();
|
|
10
|
+
const paramsMatch = fnStr.match(/^[^{=]+\(([^)]*)\)/);
|
|
11
|
+
if (paramsMatch && paramsMatch[1].includes('...')) {
|
|
12
|
+
throw new Error(`[Zenith] ${filePath}: "${exportName}(ctx)" must not contain rest parameters.`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function validateServerExports({ exports, filePath, routeKind = 'page' }) {
|
|
16
|
+
const exportKeys = Object.keys(exports);
|
|
17
|
+
const allowedKeys = routeKind === 'resource' ? RESOURCE_ALLOWED_KEYS : ALLOWED_KEYS;
|
|
18
|
+
const illegalKeys = exportKeys.filter((key) => !allowedKeys.has(key));
|
|
19
|
+
if (illegalKeys.length > 0) {
|
|
20
|
+
throw new Error(`[Zenith] ${filePath}: illegal export(s): ${illegalKeys.join(', ')}`);
|
|
21
|
+
}
|
|
22
|
+
const hasData = 'data' in exports;
|
|
23
|
+
const hasLoad = 'load' in exports;
|
|
24
|
+
const hasGuard = 'guard' in exports;
|
|
25
|
+
const hasAction = 'action' in exports;
|
|
26
|
+
const hasNew = hasData || hasLoad || hasAction;
|
|
27
|
+
const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
|
|
28
|
+
if (routeKind === 'resource') {
|
|
29
|
+
if (hasData) {
|
|
30
|
+
throw new Error(`[Zenith] ${filePath}: resource routes may not export "data". Use load(ctx) or action(ctx) with ctx.json()/ctx.text().`);
|
|
31
|
+
}
|
|
32
|
+
if (!hasLoad && !hasAction) {
|
|
33
|
+
throw new Error(`[Zenith] ${filePath}: resource routes must export load(ctx), action(ctx), or both.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (hasData && hasLoad) {
|
|
37
|
+
throw new Error(`[Zenith] ${filePath}: cannot export both "data" and "load". Choose one.`);
|
|
38
|
+
}
|
|
39
|
+
if (routeKind === 'page' && hasNew && hasLegacy) {
|
|
40
|
+
throw new Error(`[Zenith] ${filePath}: cannot mix new ("data"/"load") with legacy ("ssr_data"/"props"/"ssr") exports.`);
|
|
41
|
+
}
|
|
42
|
+
if (routeKind === 'page' && 'prerender' in exports && typeof exports.prerender !== 'boolean') {
|
|
43
|
+
throw new Error(`[Zenith] ${filePath}: "prerender" must be a boolean.`);
|
|
44
|
+
}
|
|
45
|
+
if (routeKind === 'page' && 'exportPaths' in exports) {
|
|
46
|
+
if (!Array.isArray(exports.exportPaths) || exports.exportPaths.some((value) => typeof value !== 'string')) {
|
|
47
|
+
throw new Error(`[Zenith] ${filePath}: "exportPaths" must be an array of string pathnames.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (hasLoad) {
|
|
51
|
+
assertOneArgRouteFunction({ filePath, exportName: 'load', value: exports.load });
|
|
52
|
+
}
|
|
53
|
+
if (hasGuard) {
|
|
54
|
+
assertOneArgRouteFunction({ filePath, exportName: 'guard', value: exports.guard });
|
|
55
|
+
}
|
|
56
|
+
if (hasAction) {
|
|
57
|
+
assertOneArgRouteFunction({ filePath, exportName: 'action', value: exports.action });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function assertJsonSerializable(value: any, where?: string): void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function assertJsonSerializable(value, where = 'payload') {
|
|
2
|
+
const seen = new Set();
|
|
3
|
+
function walk(v, path) {
|
|
4
|
+
const t = typeof v;
|
|
5
|
+
if (v === null)
|
|
6
|
+
return;
|
|
7
|
+
if (t === 'string' || t === 'number' || t === 'boolean')
|
|
8
|
+
return;
|
|
9
|
+
if (t === 'bigint' || t === 'function' || t === 'symbol') {
|
|
10
|
+
throw new Error(`[Zenith] ${where}: non-serializable ${t} at ${path}`);
|
|
11
|
+
}
|
|
12
|
+
if (t === 'undefined') {
|
|
13
|
+
throw new Error(`[Zenith] ${where}: undefined is not allowed at ${path}`);
|
|
14
|
+
}
|
|
15
|
+
if (v instanceof Date) {
|
|
16
|
+
throw new Error(`[Zenith] ${where}: Date is not allowed at ${path} (convert to ISO string)`);
|
|
17
|
+
}
|
|
18
|
+
if (v instanceof Map || v instanceof Set) {
|
|
19
|
+
throw new Error(`[Zenith] ${where}: Map/Set not allowed at ${path}`);
|
|
20
|
+
}
|
|
21
|
+
if (t === 'object') {
|
|
22
|
+
if (seen.has(v))
|
|
23
|
+
throw new Error(`[Zenith] ${where}: circular reference at ${path}`);
|
|
24
|
+
seen.add(v);
|
|
25
|
+
if (Array.isArray(v)) {
|
|
26
|
+
if (path === '$') {
|
|
27
|
+
throw new Error(`[Zenith] ${where}: top-level payload must be a plain object, not an array at ${path}`);
|
|
28
|
+
}
|
|
29
|
+
for (let i = 0; i < v.length; i += 1) {
|
|
30
|
+
walk(v[i], `${path}[${i}]`);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const proto = Object.getPrototypeOf(v);
|
|
35
|
+
const isPlainObject = proto === null ||
|
|
36
|
+
proto === Object.prototype ||
|
|
37
|
+
(proto && proto.constructor && proto.constructor.name === 'Object');
|
|
38
|
+
if (!isPlainObject) {
|
|
39
|
+
throw new Error(`[Zenith] ${where}: non-plain object at ${path}`);
|
|
40
|
+
}
|
|
41
|
+
for (const key of Object.keys(v)) {
|
|
42
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
43
|
+
throw new Error(`[Zenith] ${where}: forbidden prototype pollution key "${key}" at ${path}.${key}`);
|
|
44
|
+
}
|
|
45
|
+
walk(v[key], `${path}.${key}`);
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`[Zenith] ${where}: unsupported type at ${path}`);
|
|
50
|
+
}
|
|
51
|
+
walk(value, '$');
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function resolveRouteResult({ exports, ctx, filePath, guardOnly, routeKind }: {
|
|
2
|
+
exports: any;
|
|
3
|
+
ctx: any;
|
|
4
|
+
filePath: any;
|
|
5
|
+
guardOnly?: boolean | undefined;
|
|
6
|
+
routeKind?: string | undefined;
|
|
7
|
+
}): Promise<{
|
|
8
|
+
result: any;
|
|
9
|
+
trace: any;
|
|
10
|
+
}>;
|
|
11
|
+
export function resolveServerPayload({ exports, ctx, filePath }: {
|
|
12
|
+
exports: any;
|
|
13
|
+
ctx: any;
|
|
14
|
+
filePath: any;
|
|
15
|
+
}): Promise<any>;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { STAGED_SET_COOKIES_KEY } from './constants.js';
|
|
2
|
+
import { validateServerExports } from './export-validation.js';
|
|
3
|
+
import { assertJsonSerializable } from './json-serializable.js';
|
|
4
|
+
import { assertValidRouteResultShape, isRouteResultLike } from './route-result-validation.js';
|
|
5
|
+
import { allow, data, text } from './result-helpers.js';
|
|
6
|
+
import { invokeRouteStage } from './stage.js';
|
|
7
|
+
function buildActionState(result) {
|
|
8
|
+
if (!result || typeof result !== 'object') {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
if (result.kind === 'data') {
|
|
12
|
+
return {
|
|
13
|
+
ok: true,
|
|
14
|
+
status: 200,
|
|
15
|
+
data: result.data
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (result.kind === 'invalid') {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
status: Number.isInteger(result.status) ? result.status : 400,
|
|
22
|
+
data: result.data
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function buildResolvedEnvelope({ result, trace, status, ctx }) {
|
|
28
|
+
const envelope = { result, trace };
|
|
29
|
+
if (status !== undefined) {
|
|
30
|
+
envelope.status = status;
|
|
31
|
+
}
|
|
32
|
+
const setCookies = Array.isArray(ctx?.[STAGED_SET_COOKIES_KEY])
|
|
33
|
+
? ctx[STAGED_SET_COOKIES_KEY].slice()
|
|
34
|
+
: [];
|
|
35
|
+
if (setCookies.length > 0) {
|
|
36
|
+
envelope.setCookies = setCookies;
|
|
37
|
+
}
|
|
38
|
+
return envelope;
|
|
39
|
+
}
|
|
40
|
+
export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = false, routeKind = 'page' }) {
|
|
41
|
+
validateServerExports({ exports, filePath, routeKind });
|
|
42
|
+
if (routeKind === 'resource') {
|
|
43
|
+
return resolveResourceRouteResult({ exports, ctx, filePath, guardOnly });
|
|
44
|
+
}
|
|
45
|
+
const trace = {
|
|
46
|
+
guard: 'none',
|
|
47
|
+
action: 'none',
|
|
48
|
+
load: 'none'
|
|
49
|
+
};
|
|
50
|
+
let responseStatus = 200;
|
|
51
|
+
const requestMethod = String(ctx?.method || ctx?.request?.method || 'GET').toUpperCase();
|
|
52
|
+
const isActionRequest = !guardOnly && requestMethod === 'POST';
|
|
53
|
+
if (ctx && typeof ctx === 'object') {
|
|
54
|
+
ctx.action = null;
|
|
55
|
+
}
|
|
56
|
+
if ('guard' in exports) {
|
|
57
|
+
const guardRaw = await invokeRouteStage({
|
|
58
|
+
fn: exports.guard,
|
|
59
|
+
ctx,
|
|
60
|
+
where: `${filePath}: guard(ctx)`,
|
|
61
|
+
allowedKinds: new Set(['allow', 'redirect', 'deny'])
|
|
62
|
+
});
|
|
63
|
+
const guardResult = guardRaw == null ? allow() : guardRaw;
|
|
64
|
+
if (guardResult.kind === 'data') {
|
|
65
|
+
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.`);
|
|
66
|
+
}
|
|
67
|
+
assertValidRouteResultShape(guardResult, `${filePath}: guard(ctx) return`, new Set(['allow', 'redirect', 'deny']));
|
|
68
|
+
trace.guard = guardResult.kind;
|
|
69
|
+
if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
|
|
70
|
+
return buildResolvedEnvelope({ result: guardResult, trace, ctx });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (guardOnly) {
|
|
74
|
+
return buildResolvedEnvelope({ result: allow(), trace, ctx });
|
|
75
|
+
}
|
|
76
|
+
if (isActionRequest && 'action' in exports) {
|
|
77
|
+
const actionRaw = await invokeRouteStage({
|
|
78
|
+
fn: exports.action,
|
|
79
|
+
ctx,
|
|
80
|
+
where: `${filePath}: action(ctx)`,
|
|
81
|
+
allowedKinds: new Set(['data', 'invalid', 'redirect', 'deny'])
|
|
82
|
+
});
|
|
83
|
+
let actionResult = null;
|
|
84
|
+
if (isRouteResultLike(actionRaw)) {
|
|
85
|
+
actionResult = actionRaw;
|
|
86
|
+
assertValidRouteResultShape(actionResult, `${filePath}: action(ctx) return`, new Set(['data', 'invalid', 'redirect', 'deny']));
|
|
87
|
+
if (actionResult.kind === 'data' || actionResult.kind === 'invalid') {
|
|
88
|
+
assertJsonSerializable(actionResult.data, `${filePath}: action(ctx) return`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
assertJsonSerializable(actionRaw, `${filePath}: action(ctx) return`);
|
|
93
|
+
actionResult = data(actionRaw);
|
|
94
|
+
}
|
|
95
|
+
trace.action = actionResult.kind;
|
|
96
|
+
if (actionResult.kind === 'redirect' || actionResult.kind === 'deny') {
|
|
97
|
+
return buildResolvedEnvelope({ result: actionResult, trace, ctx });
|
|
98
|
+
}
|
|
99
|
+
const actionState = buildActionState(actionResult);
|
|
100
|
+
if (ctx && typeof ctx === 'object') {
|
|
101
|
+
ctx.action = actionState;
|
|
102
|
+
}
|
|
103
|
+
if (actionState && actionState.ok === false) {
|
|
104
|
+
responseStatus = actionState.status;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let payload;
|
|
108
|
+
if ('load' in exports) {
|
|
109
|
+
const loadRaw = await invokeRouteStage({
|
|
110
|
+
fn: exports.load,
|
|
111
|
+
ctx,
|
|
112
|
+
where: `${filePath}: load(ctx)`,
|
|
113
|
+
allowedKinds: new Set(['data', 'redirect', 'deny'])
|
|
114
|
+
});
|
|
115
|
+
let loadResult = null;
|
|
116
|
+
if (isRouteResultLike(loadRaw)) {
|
|
117
|
+
loadResult = loadRaw;
|
|
118
|
+
assertValidRouteResultShape(loadResult, `${filePath}: load(ctx) return`, new Set(['data', 'redirect', 'deny']));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
assertJsonSerializable(loadRaw, `${filePath}: load(ctx) return`);
|
|
122
|
+
loadResult = data(loadRaw);
|
|
123
|
+
}
|
|
124
|
+
trace.load = loadResult.kind;
|
|
125
|
+
return buildResolvedEnvelope({
|
|
126
|
+
result: loadResult,
|
|
127
|
+
trace,
|
|
128
|
+
status: loadResult.kind === 'data' ? responseStatus : undefined,
|
|
129
|
+
ctx
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if ('data' in exports) {
|
|
133
|
+
payload = exports.data;
|
|
134
|
+
assertJsonSerializable(payload, `${filePath}: data export`);
|
|
135
|
+
trace.load = 'data';
|
|
136
|
+
return buildResolvedEnvelope({ result: data(payload), trace, status: responseStatus, ctx });
|
|
137
|
+
}
|
|
138
|
+
// legacy fallback
|
|
139
|
+
if ('ssr_data' in exports) {
|
|
140
|
+
payload = exports.ssr_data;
|
|
141
|
+
assertJsonSerializable(payload, `${filePath}: ssr_data export`);
|
|
142
|
+
trace.load = 'data';
|
|
143
|
+
return buildResolvedEnvelope({ result: data(payload), trace, status: responseStatus, ctx });
|
|
144
|
+
}
|
|
145
|
+
if ('props' in exports) {
|
|
146
|
+
payload = exports.props;
|
|
147
|
+
assertJsonSerializable(payload, `${filePath}: props export`);
|
|
148
|
+
trace.load = 'data';
|
|
149
|
+
return buildResolvedEnvelope({ result: data(payload), trace, status: responseStatus, ctx });
|
|
150
|
+
}
|
|
151
|
+
if ('ssr' in exports) {
|
|
152
|
+
payload = exports.ssr;
|
|
153
|
+
assertJsonSerializable(payload, `${filePath}: ssr export`);
|
|
154
|
+
trace.load = 'data';
|
|
155
|
+
return buildResolvedEnvelope({ result: data(payload), trace, status: responseStatus, ctx });
|
|
156
|
+
}
|
|
157
|
+
if (isActionRequest && ctx?.action) {
|
|
158
|
+
trace.load = 'data';
|
|
159
|
+
return buildResolvedEnvelope({
|
|
160
|
+
result: data({ action: ctx.action }),
|
|
161
|
+
trace,
|
|
162
|
+
status: responseStatus,
|
|
163
|
+
ctx
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return buildResolvedEnvelope({ result: data({}), trace, status: responseStatus, ctx });
|
|
167
|
+
}
|
|
168
|
+
async function resolveResourceRouteResult({ exports, ctx, filePath, guardOnly = false }) {
|
|
169
|
+
const trace = {
|
|
170
|
+
guard: 'none',
|
|
171
|
+
action: 'none',
|
|
172
|
+
load: 'none'
|
|
173
|
+
};
|
|
174
|
+
const requestMethod = String(ctx?.method || ctx?.request?.method || 'GET').toUpperCase();
|
|
175
|
+
if (ctx && typeof ctx === 'object') {
|
|
176
|
+
ctx.action = null;
|
|
177
|
+
}
|
|
178
|
+
if ('guard' in exports) {
|
|
179
|
+
const guardRaw = await invokeRouteStage({
|
|
180
|
+
fn: exports.guard,
|
|
181
|
+
ctx,
|
|
182
|
+
where: `${filePath}: guard(ctx)`,
|
|
183
|
+
allowedKinds: new Set(['allow', 'redirect', 'deny'])
|
|
184
|
+
});
|
|
185
|
+
const guardResult = guardRaw == null ? allow() : guardRaw;
|
|
186
|
+
assertValidRouteResultShape(guardResult, `${filePath}: guard(ctx) return`, new Set(['allow', 'redirect', 'deny']));
|
|
187
|
+
trace.guard = guardResult.kind;
|
|
188
|
+
if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
|
|
189
|
+
return buildResolvedEnvelope({ result: guardResult, trace, ctx });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (guardOnly) {
|
|
193
|
+
return buildResolvedEnvelope({ result: allow(), trace, ctx });
|
|
194
|
+
}
|
|
195
|
+
if (requestMethod === 'GET' || requestMethod === 'HEAD') {
|
|
196
|
+
if (!('load' in exports)) {
|
|
197
|
+
trace.load = 'text';
|
|
198
|
+
return buildResolvedEnvelope({ result: text('Method Not Allowed', 405), trace, status: 405, ctx });
|
|
199
|
+
}
|
|
200
|
+
const loadResult = await resolveResourceStage({
|
|
201
|
+
exports,
|
|
202
|
+
exportName: 'load',
|
|
203
|
+
ctx,
|
|
204
|
+
filePath,
|
|
205
|
+
trace,
|
|
206
|
+
traceKey: 'load'
|
|
207
|
+
});
|
|
208
|
+
return buildResolvedEnvelope({
|
|
209
|
+
result: loadResult,
|
|
210
|
+
trace,
|
|
211
|
+
status: loadResult.status,
|
|
212
|
+
ctx
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (requestMethod === 'POST') {
|
|
216
|
+
if (!('action' in exports)) {
|
|
217
|
+
trace.action = 'text';
|
|
218
|
+
return buildResolvedEnvelope({ result: text('Method Not Allowed', 405), trace, status: 405, ctx });
|
|
219
|
+
}
|
|
220
|
+
const actionResult = await resolveResourceStage({
|
|
221
|
+
exports,
|
|
222
|
+
exportName: 'action',
|
|
223
|
+
ctx,
|
|
224
|
+
filePath,
|
|
225
|
+
trace,
|
|
226
|
+
traceKey: 'action'
|
|
227
|
+
});
|
|
228
|
+
return buildResolvedEnvelope({
|
|
229
|
+
result: actionResult,
|
|
230
|
+
trace,
|
|
231
|
+
status: actionResult.status,
|
|
232
|
+
ctx
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return buildResolvedEnvelope({
|
|
236
|
+
result: text('Method Not Allowed', 405),
|
|
237
|
+
trace,
|
|
238
|
+
status: 405,
|
|
239
|
+
ctx
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
async function resolveResourceStage({ exports, exportName, ctx, filePath, trace, traceKey }) {
|
|
243
|
+
const raw = await invokeRouteStage({
|
|
244
|
+
fn: exports[exportName],
|
|
245
|
+
ctx,
|
|
246
|
+
where: `${filePath}: ${exportName}(ctx)`,
|
|
247
|
+
allowedKinds: new Set(['json', 'text', 'download', 'redirect', 'deny', 'invalid', 'stream', 'sse'])
|
|
248
|
+
});
|
|
249
|
+
if (!isRouteResultLike(raw)) {
|
|
250
|
+
throw new Error(`[Zenith] ${filePath}: ${exportName}(ctx) on a resource route must return json(...), text(...), download(...), redirect(...), deny(...), invalid(...), stream(...), or sse(...).`);
|
|
251
|
+
}
|
|
252
|
+
assertValidRouteResultShape(raw, `${filePath}: ${exportName}(ctx) return`, new Set(['json', 'text', 'download', 'redirect', 'deny', 'invalid', 'stream', 'sse']));
|
|
253
|
+
if (raw.kind === 'json' || raw.kind === 'invalid') {
|
|
254
|
+
assertJsonSerializable(raw.data, `${filePath}: ${exportName}(ctx) return`);
|
|
255
|
+
}
|
|
256
|
+
trace[traceKey] = raw.kind;
|
|
257
|
+
return raw;
|
|
258
|
+
}
|
|
259
|
+
export async function resolveServerPayload({ exports, ctx, filePath }) {
|
|
260
|
+
const resolved = await resolveRouteResult({ exports, ctx, filePath });
|
|
261
|
+
if (!resolved || !resolved.result || typeof resolved.result !== 'object') {
|
|
262
|
+
return {};
|
|
263
|
+
}
|
|
264
|
+
if (resolved.result.kind === 'data') {
|
|
265
|
+
return resolved.result.data;
|
|
266
|
+
}
|
|
267
|
+
if (resolved.result.kind === 'allow') {
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
throw new Error(`[Zenith] ${filePath}: resolveServerPayload() expected data but received ${resolved.result.kind}. Use resolveRouteResult() for guard/load flows.`);
|
|
271
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function allow(): {
|
|
2
|
+
kind: string;
|
|
3
|
+
};
|
|
4
|
+
export function redirect(location: any, status?: number): {
|
|
5
|
+
kind: string;
|
|
6
|
+
location: string;
|
|
7
|
+
status: number;
|
|
8
|
+
};
|
|
9
|
+
export function deny(status?: number, message?: undefined): {
|
|
10
|
+
kind: string;
|
|
11
|
+
status: number;
|
|
12
|
+
message: undefined;
|
|
13
|
+
};
|
|
14
|
+
export function data(payload: any): {
|
|
15
|
+
kind: string;
|
|
16
|
+
data: any;
|
|
17
|
+
};
|
|
18
|
+
export function invalid(payload: any, status?: number): {
|
|
19
|
+
kind: string;
|
|
20
|
+
data: any;
|
|
21
|
+
status: number;
|
|
22
|
+
};
|
|
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 stream(body: any, options?: {}): {
|
|
43
|
+
kind: string;
|
|
44
|
+
body: any;
|
|
45
|
+
status: any;
|
|
46
|
+
contentType: any;
|
|
47
|
+
};
|
|
48
|
+
export function sse(events: any): {
|
|
49
|
+
kind: string;
|
|
50
|
+
events: any;
|
|
51
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createDownloadResult } from '../download-result.js';
|
|
2
|
+
export function allow() {
|
|
3
|
+
return { kind: 'allow' };
|
|
4
|
+
}
|
|
5
|
+
export function redirect(location, status = 302) {
|
|
6
|
+
return {
|
|
7
|
+
kind: 'redirect',
|
|
8
|
+
location: String(location || ''),
|
|
9
|
+
status: Number.isInteger(status) ? status : 302
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function deny(status = 403, message = undefined) {
|
|
13
|
+
return {
|
|
14
|
+
kind: 'deny',
|
|
15
|
+
status: Number.isInteger(status) ? status : 403,
|
|
16
|
+
message: typeof message === 'string' ? message : undefined
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function data(payload) {
|
|
20
|
+
return { kind: 'data', data: payload };
|
|
21
|
+
}
|
|
22
|
+
export function invalid(payload, status = 400) {
|
|
23
|
+
return {
|
|
24
|
+
kind: 'invalid',
|
|
25
|
+
data: payload,
|
|
26
|
+
status: Number.isInteger(status) ? status : 400
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function json(payload, status = 200) {
|
|
30
|
+
return {
|
|
31
|
+
kind: 'json',
|
|
32
|
+
data: payload,
|
|
33
|
+
status: Number.isInteger(status) ? status : 200
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function text(body, status = 200) {
|
|
37
|
+
return {
|
|
38
|
+
kind: 'text',
|
|
39
|
+
body: typeof body === 'string' ? body : String(body ?? ''),
|
|
40
|
+
status: Number.isInteger(status) ? status : 200
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function download(body, options = {}) {
|
|
44
|
+
return createDownloadResult(body, options);
|
|
45
|
+
}
|
|
46
|
+
export function stream(body, options = {}) {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'stream',
|
|
49
|
+
body,
|
|
50
|
+
status: options?.status,
|
|
51
|
+
contentType: options?.contentType
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function sse(events) {
|
|
55
|
+
return {
|
|
56
|
+
kind: 'sse',
|
|
57
|
+
events
|
|
58
|
+
};
|
|
59
|
+
}
|