@mandujs/core 0.30.0 → 0.32.0
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/package.json +7 -1
- package/src/client/index.ts +11 -1
- package/src/client/rpc.ts +293 -140
- package/src/config/mandu.ts +107 -1
- package/src/config/validate.ts +145 -1
- package/src/contract/index.ts +18 -0
- package/src/contract/rpc.ts +443 -0
- package/src/filling/context.ts +60 -0
- package/src/guard/check.ts +225 -1
- package/src/guard/define-rule.ts +243 -0
- package/src/guard/index.ts +26 -0
- package/src/guard/rule-presets.ts +379 -0
- package/src/i18n/define.ts +126 -0
- package/src/i18n/index.ts +52 -0
- package/src/i18n/locale-resolver.ts +214 -0
- package/src/i18n/message-registry.ts +173 -0
- package/src/i18n/types.ts +112 -0
- package/src/middleware/index.ts +7 -0
- package/src/middleware/scheduler-cron.ts +96 -0
- package/src/router/fs-scanner.ts +101 -0
- package/src/router/index.ts +7 -1
- package/src/runtime/server.ts +432 -9
- package/src/runtime/ssr.ts +9 -0
- package/src/scheduler/index.ts +547 -343
- package/src/scheduler/validate.ts +169 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"./auth/reset": "./src/auth/reset.ts",
|
|
14
14
|
"./auth/verification": "./src/auth/verification.ts",
|
|
15
15
|
"./client": "./src/client/index.ts",
|
|
16
|
+
"./client/rpc": "./src/client/rpc.ts",
|
|
17
|
+
"./contract": "./src/contract/index.ts",
|
|
18
|
+
"./contract/rpc": "./src/contract/rpc.ts",
|
|
16
19
|
"./content": "./src/content/index.ts",
|
|
17
20
|
"./content/prebuild": "./src/content/prebuild.ts",
|
|
18
21
|
"./content/collection": "./src/content/collection.ts",
|
|
@@ -33,6 +36,7 @@
|
|
|
33
36
|
"./testing": "./src/testing/index.ts",
|
|
34
37
|
"./plugins": "./src/plugins/index.ts",
|
|
35
38
|
"./error": "./src/error/index.ts",
|
|
39
|
+
"./i18n": "./src/i18n/index.ts",
|
|
36
40
|
"./id": "./src/id/index.ts",
|
|
37
41
|
"./observability": "./src/observability/index.ts",
|
|
38
42
|
"./perf": "./src/perf/index.ts",
|
|
@@ -40,6 +44,8 @@
|
|
|
40
44
|
"./routes": "./src/routes/index.ts",
|
|
41
45
|
"./scheduler": "./src/scheduler/index.ts",
|
|
42
46
|
"./storage/s3": "./src/storage/s3/index.ts",
|
|
47
|
+
"./guard/define-rule": "./src/guard/define-rule.ts",
|
|
48
|
+
"./guard/rule-presets": "./src/guard/rule-presets.ts",
|
|
43
49
|
"./bundler/prerender": "./src/bundler/prerender.ts",
|
|
44
50
|
"./bundler/safe-build": "./src/bundler/safe-build.ts",
|
|
45
51
|
"./bundler/hmr-types": "./src/bundler/hmr-types.ts",
|
package/src/client/index.ts
CHANGED
|
@@ -112,7 +112,17 @@ export { Link, NavLink, type LinkProps, type NavLinkProps } from "./Link";
|
|
|
112
112
|
export { Form, type FormProps, type FormState } from "./Form";
|
|
113
113
|
|
|
114
114
|
// RPC Client
|
|
115
|
-
export {
|
|
115
|
+
export {
|
|
116
|
+
createClient,
|
|
117
|
+
RpcError,
|
|
118
|
+
type RpcMethods,
|
|
119
|
+
type RpcRequestOptions,
|
|
120
|
+
type RpcClientOptions,
|
|
121
|
+
// Phase 18.κ — typed RPC proxy
|
|
122
|
+
createRpcClient,
|
|
123
|
+
RpcCallError,
|
|
124
|
+
type CreateRpcClientOptions,
|
|
125
|
+
} from "./rpc";
|
|
116
126
|
|
|
117
127
|
// useFetch Composable
|
|
118
128
|
export { useFetch, type UseFetchOptions, type UseFetchReturn } from "./use-fetch";
|
package/src/client/rpc.ts
CHANGED
|
@@ -1,140 +1,293 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu RPC Client
|
|
3
|
-
* Contract 정의에서 타입 안전한 API 클라이언트 생성
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// ========== Types ==========
|
|
7
|
-
|
|
8
|
-
/** RPC 클라이언트 에러 */
|
|
9
|
-
export class RpcError extends Error {
|
|
10
|
-
constructor(
|
|
11
|
-
public readonly status: number,
|
|
12
|
-
public readonly body: unknown
|
|
13
|
-
) {
|
|
14
|
-
super(`API Error ${status}`);
|
|
15
|
-
this.name = "RpcError";
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface RpcRequestOptions {
|
|
20
|
-
query?: Record<string, unknown>;
|
|
21
|
-
body?: unknown;
|
|
22
|
-
params?: Record<string, string>;
|
|
23
|
-
headers?: Record<string, string>;
|
|
24
|
-
signal?: AbortSignal;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface RpcClientOptions {
|
|
28
|
-
/** API base URL (기본: 현재 origin) */
|
|
29
|
-
baseUrl?: string;
|
|
30
|
-
/** 공통 헤더 */
|
|
31
|
-
headers?: Record<string, string>;
|
|
32
|
-
/** 커스텀 fetch (테스트용) */
|
|
33
|
-
fetch?: typeof globalThis.fetch;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ========== Implementation ==========
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Contract 기반 타입 안전 RPC 클라이언트 생성
|
|
40
|
-
*
|
|
41
|
-
* @example
|
|
42
|
-
* ```typescript
|
|
43
|
-
* import { createClient } from "@mandujs/core/client";
|
|
44
|
-
* import type todoContract from "../spec/contracts/api-todos.contract";
|
|
45
|
-
*
|
|
46
|
-
* const api = createClient<typeof todoContract>("/api/todos");
|
|
47
|
-
*
|
|
48
|
-
* // 타입 추론 동작
|
|
49
|
-
* const { todos } = await api.get({ query: { page: 2 } });
|
|
50
|
-
* const { id } = await api.post({ body: { title: "New" } });
|
|
51
|
-
* ```
|
|
52
|
-
*/
|
|
53
|
-
export function createClient<TContract = unknown>(
|
|
54
|
-
path: string,
|
|
55
|
-
options?: RpcClientOptions
|
|
56
|
-
): RpcMethods {
|
|
57
|
-
const baseFetch = options?.fetch ?? globalThis.fetch;
|
|
58
|
-
const baseUrl = options?.baseUrl ?? "";
|
|
59
|
-
const baseHeaders = options?.headers ?? {};
|
|
60
|
-
|
|
61
|
-
function makeRequest(method: string) {
|
|
62
|
-
return async (input?: RpcRequestOptions): Promise<unknown> => {
|
|
63
|
-
const url = new URL(`${baseUrl}${path}`, typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
|
64
|
-
|
|
65
|
-
// URL 파라미터 치환
|
|
66
|
-
if (input?.params) {
|
|
67
|
-
let resolvedPath = url.pathname;
|
|
68
|
-
for (const [key, value] of Object.entries(input.params)) {
|
|
69
|
-
resolvedPath = resolvedPath.replace(`:${key}`, encodeURIComponent(value));
|
|
70
|
-
}
|
|
71
|
-
// 미해결 파라미터 검출
|
|
72
|
-
if (resolvedPath.includes(":")) {
|
|
73
|
-
throw new RpcError(0, `Unresolved path params in "${resolvedPath}". Check your params object.`);
|
|
74
|
-
}
|
|
75
|
-
url.pathname = resolvedPath;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Query 파라미터
|
|
79
|
-
if (input?.query) {
|
|
80
|
-
for (const [key, value] of Object.entries(input.query)) {
|
|
81
|
-
if (value !== undefined && value !== null) {
|
|
82
|
-
url.searchParams.set(key, String(value));
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const headers: Record<string, string> = {
|
|
88
|
-
...baseHeaders,
|
|
89
|
-
...input?.headers,
|
|
90
|
-
"Accept": "application/json",
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const fetchOptions: RequestInit = {
|
|
94
|
-
method: method.toUpperCase(),
|
|
95
|
-
headers,
|
|
96
|
-
signal: input?.signal,
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
// Body (GET/HEAD 제외)
|
|
100
|
-
if (input?.body && method !== "GET" && method !== "HEAD") {
|
|
101
|
-
fetchOptions.body = JSON.stringify(input.body);
|
|
102
|
-
headers["Content-Type"] = "application/json";
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const response = await baseFetch(url.toString(), fetchOptions);
|
|
106
|
-
|
|
107
|
-
if (!response.ok) {
|
|
108
|
-
let body: unknown;
|
|
109
|
-
try {
|
|
110
|
-
body = await response.json();
|
|
111
|
-
} catch {
|
|
112
|
-
body = await response.text().catch(() => null);
|
|
113
|
-
}
|
|
114
|
-
throw new RpcError(response.status, body);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
118
|
-
if (contentType.includes("application/json")) {
|
|
119
|
-
return response.json();
|
|
120
|
-
}
|
|
121
|
-
return response.text();
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
get: makeRequest("GET"),
|
|
127
|
-
post: makeRequest("POST"),
|
|
128
|
-
put: makeRequest("PUT"),
|
|
129
|
-
patch: makeRequest("PATCH"),
|
|
130
|
-
delete: makeRequest("DELETE"),
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export interface RpcMethods {
|
|
135
|
-
get: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
136
|
-
post: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
137
|
-
put: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
138
|
-
patch: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
139
|
-
delete: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
140
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu RPC Client
|
|
3
|
+
* Contract 정의에서 타입 안전한 API 클라이언트 생성
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ========== Types ==========
|
|
7
|
+
|
|
8
|
+
/** RPC 클라이언트 에러 */
|
|
9
|
+
export class RpcError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly status: number,
|
|
12
|
+
public readonly body: unknown
|
|
13
|
+
) {
|
|
14
|
+
super(`API Error ${status}`);
|
|
15
|
+
this.name = "RpcError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RpcRequestOptions {
|
|
20
|
+
query?: Record<string, unknown>;
|
|
21
|
+
body?: unknown;
|
|
22
|
+
params?: Record<string, string>;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RpcClientOptions {
|
|
28
|
+
/** API base URL (기본: 현재 origin) */
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
/** 공통 헤더 */
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
/** 커스텀 fetch (테스트용) */
|
|
33
|
+
fetch?: typeof globalThis.fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ========== Implementation ==========
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Contract 기반 타입 안전 RPC 클라이언트 생성
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* import { createClient } from "@mandujs/core/client";
|
|
44
|
+
* import type todoContract from "../spec/contracts/api-todos.contract";
|
|
45
|
+
*
|
|
46
|
+
* const api = createClient<typeof todoContract>("/api/todos");
|
|
47
|
+
*
|
|
48
|
+
* // 타입 추론 동작
|
|
49
|
+
* const { todos } = await api.get({ query: { page: 2 } });
|
|
50
|
+
* const { id } = await api.post({ body: { title: "New" } });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function createClient<TContract = unknown>(
|
|
54
|
+
path: string,
|
|
55
|
+
options?: RpcClientOptions
|
|
56
|
+
): RpcMethods {
|
|
57
|
+
const baseFetch = options?.fetch ?? globalThis.fetch;
|
|
58
|
+
const baseUrl = options?.baseUrl ?? "";
|
|
59
|
+
const baseHeaders = options?.headers ?? {};
|
|
60
|
+
|
|
61
|
+
function makeRequest(method: string) {
|
|
62
|
+
return async (input?: RpcRequestOptions): Promise<unknown> => {
|
|
63
|
+
const url = new URL(`${baseUrl}${path}`, typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
|
64
|
+
|
|
65
|
+
// URL 파라미터 치환
|
|
66
|
+
if (input?.params) {
|
|
67
|
+
let resolvedPath = url.pathname;
|
|
68
|
+
for (const [key, value] of Object.entries(input.params)) {
|
|
69
|
+
resolvedPath = resolvedPath.replace(`:${key}`, encodeURIComponent(value));
|
|
70
|
+
}
|
|
71
|
+
// 미해결 파라미터 검출
|
|
72
|
+
if (resolvedPath.includes(":")) {
|
|
73
|
+
throw new RpcError(0, `Unresolved path params in "${resolvedPath}". Check your params object.`);
|
|
74
|
+
}
|
|
75
|
+
url.pathname = resolvedPath;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Query 파라미터
|
|
79
|
+
if (input?.query) {
|
|
80
|
+
for (const [key, value] of Object.entries(input.query)) {
|
|
81
|
+
if (value !== undefined && value !== null) {
|
|
82
|
+
url.searchParams.set(key, String(value));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const headers: Record<string, string> = {
|
|
88
|
+
...baseHeaders,
|
|
89
|
+
...input?.headers,
|
|
90
|
+
"Accept": "application/json",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const fetchOptions: RequestInit = {
|
|
94
|
+
method: method.toUpperCase(),
|
|
95
|
+
headers,
|
|
96
|
+
signal: input?.signal,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Body (GET/HEAD 제외)
|
|
100
|
+
if (input?.body && method !== "GET" && method !== "HEAD") {
|
|
101
|
+
fetchOptions.body = JSON.stringify(input.body);
|
|
102
|
+
headers["Content-Type"] = "application/json";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const response = await baseFetch(url.toString(), fetchOptions);
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
let body: unknown;
|
|
109
|
+
try {
|
|
110
|
+
body = await response.json();
|
|
111
|
+
} catch {
|
|
112
|
+
body = await response.text().catch(() => null);
|
|
113
|
+
}
|
|
114
|
+
throw new RpcError(response.status, body);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
118
|
+
if (contentType.includes("application/json")) {
|
|
119
|
+
return response.json();
|
|
120
|
+
}
|
|
121
|
+
return response.text();
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
get: makeRequest("GET"),
|
|
127
|
+
post: makeRequest("POST"),
|
|
128
|
+
put: makeRequest("PUT"),
|
|
129
|
+
patch: makeRequest("PATCH"),
|
|
130
|
+
delete: makeRequest("DELETE"),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface RpcMethods {
|
|
135
|
+
get: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
136
|
+
post: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
137
|
+
put: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
138
|
+
patch: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
139
|
+
delete: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
143
|
+
// Phase 18.κ — Typed RPC Client (tRPC-like)
|
|
144
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
145
|
+
//
|
|
146
|
+
// `createRpcClient<typeof postsRpc>()` returns a Proxy whose properties
|
|
147
|
+
// are the RPC procedure names (`list`, `get`, …). Each access produces
|
|
148
|
+
// an async function whose input type is the procedure's Zod input
|
|
149
|
+
// type and whose return type is the procedure's Zod output type.
|
|
150
|
+
//
|
|
151
|
+
// Wire protocol:
|
|
152
|
+
// POST <baseUrl>/<method>
|
|
153
|
+
// body: { "input": <value> }
|
|
154
|
+
// response: { "ok": true, "data": <value> }
|
|
155
|
+
// | { "ok": false, "error": { code, message, issues? } }
|
|
156
|
+
//
|
|
157
|
+
// Errors throw {@link RpcCallError} with the structured fields
|
|
158
|
+
// preserved so UI code can surface field-level validation issues.
|
|
159
|
+
|
|
160
|
+
import type { RpcClient, RpcDefinition, RpcProcedureRecord, RpcWireEnvelope, RpcWireError } from "../contract/rpc";
|
|
161
|
+
|
|
162
|
+
/** Options passed to {@link createRpcClient}. */
|
|
163
|
+
export interface CreateRpcClientOptions {
|
|
164
|
+
/**
|
|
165
|
+
* Absolute or site-relative base URL for the RPC endpoint —
|
|
166
|
+
* typically `/api/rpc/<name>`. The method name is appended.
|
|
167
|
+
*/
|
|
168
|
+
baseUrl: string;
|
|
169
|
+
/** Extra headers sent with every call. */
|
|
170
|
+
headers?: Record<string, string>;
|
|
171
|
+
/** Custom fetch (e.g. test double or node-fetch polyfill). */
|
|
172
|
+
fetch?: typeof globalThis.fetch;
|
|
173
|
+
/**
|
|
174
|
+
* Optional per-call AbortSignal factory. Useful when the proxy is
|
|
175
|
+
* shared across React components that want independent cancellation.
|
|
176
|
+
*/
|
|
177
|
+
signal?: AbortSignal;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Typed error thrown by {@link createRpcClient} calls on non-OK
|
|
182
|
+
* envelopes. Carries the wire-level {@link RpcWireError} + HTTP
|
|
183
|
+
* status so callers can distinguish validation vs. handler errors
|
|
184
|
+
* without string-matching on `message`.
|
|
185
|
+
*/
|
|
186
|
+
export class RpcCallError extends Error {
|
|
187
|
+
constructor(
|
|
188
|
+
public readonly status: number,
|
|
189
|
+
public readonly error: RpcWireError
|
|
190
|
+
) {
|
|
191
|
+
super(`[Mandu RPC] ${error.code}: ${error.message}`);
|
|
192
|
+
this.name = "RpcCallError";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Machine-readable code (forwarded from the server). */
|
|
196
|
+
get code(): string {
|
|
197
|
+
return this.error.code;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Field-level issues, if any. */
|
|
201
|
+
get issues(): RpcWireError["issues"] {
|
|
202
|
+
return this.error.issues;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create a typed RPC client from an `RpcDefinition` type import.
|
|
208
|
+
*
|
|
209
|
+
* The implementation uses a `Proxy` so no codegen step is required —
|
|
210
|
+
* TypeScript infers call signatures from the imported `typeof` at
|
|
211
|
+
* compile time, and at runtime every property access produces a
|
|
212
|
+
* fetch wrapper.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* import { createRpcClient } from "@mandujs/core/client";
|
|
217
|
+
* import type { postsRpc } from "../server/rpc/posts";
|
|
218
|
+
*
|
|
219
|
+
* const api = createRpcClient<typeof postsRpc>({ baseUrl: "/api/rpc/posts" });
|
|
220
|
+
* const posts = await api.list({ limit: 20 }); // fully typed
|
|
221
|
+
* const post = await api.get({ id: "abc" }); // fully typed
|
|
222
|
+
* ```
|
|
223
|
+
*
|
|
224
|
+
* Type-check failures at the call site are real compile errors:
|
|
225
|
+
* `api.list({ limit: "not-a-number" })` is a TS2322.
|
|
226
|
+
*/
|
|
227
|
+
export function createRpcClient<TDef extends RpcDefinition<RpcProcedureRecord>>(
|
|
228
|
+
options: CreateRpcClientOptions
|
|
229
|
+
): RpcClient<TDef> {
|
|
230
|
+
const baseFetch = options.fetch ?? globalThis.fetch;
|
|
231
|
+
const baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
232
|
+
const baseHeaders = options.headers ?? {};
|
|
233
|
+
|
|
234
|
+
const call = async (method: string, input: unknown): Promise<unknown> => {
|
|
235
|
+
const url = `${baseUrl}/${method}`;
|
|
236
|
+
const payload = input === undefined ? { input: undefined } : { input };
|
|
237
|
+
const response = await baseFetch(url, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: {
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
Accept: "application/json",
|
|
242
|
+
...baseHeaders,
|
|
243
|
+
},
|
|
244
|
+
body: JSON.stringify(payload),
|
|
245
|
+
signal: options.signal,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Prefer JSON parse (every legitimate RPC response is JSON), but
|
|
249
|
+
// tolerate non-JSON failure paths (e.g. upstream proxy error).
|
|
250
|
+
let envelope: RpcWireEnvelope | null = null;
|
|
251
|
+
const text = await response.text();
|
|
252
|
+
try {
|
|
253
|
+
envelope = text.length > 0 ? (JSON.parse(text) as RpcWireEnvelope) : null;
|
|
254
|
+
} catch {
|
|
255
|
+
envelope = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (envelope && envelope.ok === true) {
|
|
259
|
+
return envelope.data;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Non-OK path — throw a structured error. Prefer server-emitted
|
|
263
|
+
// envelope; fall back to a synthesized one for transport errors.
|
|
264
|
+
let error: RpcWireError;
|
|
265
|
+
if (envelope && envelope.ok === false) {
|
|
266
|
+
error = envelope.error;
|
|
267
|
+
} else {
|
|
268
|
+
error = {
|
|
269
|
+
code: response.ok ? "BAD_RESPONSE" : `HTTP_${response.status}`,
|
|
270
|
+
message:
|
|
271
|
+
text.length > 0
|
|
272
|
+
? text.slice(0, 500)
|
|
273
|
+
: `RPC call to ${url} failed with HTTP ${response.status}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
throw new RpcCallError(response.status, error);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// A Proxy on a plain object: every property access returns a
|
|
280
|
+
// pre-curried call fn. Symbol keys (`Symbol.toStringTag`, etc.) fall
|
|
281
|
+
// through so `console.log(api)` does not throw.
|
|
282
|
+
const target = Object.create(null) as Record<string, unknown>;
|
|
283
|
+
return new Proxy(target, {
|
|
284
|
+
get(_t, prop) {
|
|
285
|
+
if (typeof prop !== "string") return undefined;
|
|
286
|
+
// Allow common non-method inspection hooks to no-op.
|
|
287
|
+
if (prop === "then") return undefined; // not thenable
|
|
288
|
+
if (prop === "toJSON") return undefined;
|
|
289
|
+
return (input?: unknown) => call(prop, input);
|
|
290
|
+
},
|
|
291
|
+
}) as RpcClient<TDef>;
|
|
292
|
+
}
|
|
293
|
+
|
package/src/config/mandu.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { readJsonFile } from "../utils/bun";
|
|
|
3
3
|
import type { ManduAdapter } from "../runtime/adapter";
|
|
4
4
|
import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
|
|
5
5
|
import type { Middleware } from "../middleware/define";
|
|
6
|
+
import type { RpcDefinition, RpcProcedureRecord } from "../contract/rpc";
|
|
7
|
+
import type { CronDef } from "../scheduler";
|
|
8
|
+
import type { GuardRule as CustomGuardRule } from "../guard/define-rule";
|
|
9
|
+
import type { I18nStrategy, LocaleCode } from "../i18n/types";
|
|
6
10
|
|
|
7
11
|
export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
|
|
8
12
|
|
|
@@ -146,7 +150,20 @@ export interface ManduConfig {
|
|
|
146
150
|
srcDir?: string;
|
|
147
151
|
exclude?: string[];
|
|
148
152
|
realtime?: boolean;
|
|
149
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Built-in rule severity overrides (map) OR consumer-defined
|
|
155
|
+
* custom rules (array). The Guard runner dispatches on shape:
|
|
156
|
+
*
|
|
157
|
+
* - `Record<string, GuardRuleSeverity>` → override severity of
|
|
158
|
+
* Mandu's built-in rules by id.
|
|
159
|
+
* - `GuardRule[]` (Phase 18.ν) → register consumer-defined rules
|
|
160
|
+
* alongside the built-in presets. See `@mandujs/core/guard/define-rule`.
|
|
161
|
+
*
|
|
162
|
+
* Passing both at once is not supported; pick one shape per config.
|
|
163
|
+
* Mixed input falls back to "custom rules only" and the built-in
|
|
164
|
+
* rule severity overrides become unreachable.
|
|
165
|
+
*/
|
|
166
|
+
rules?: Record<string, GuardRuleSeverity> | CustomGuardRule[];
|
|
150
167
|
contractRequired?: GuardRuleSeverity;
|
|
151
168
|
/**
|
|
152
169
|
* Issue #207 — hard-fail on direct `__generated__/` imports at the
|
|
@@ -373,6 +390,95 @@ export interface ManduConfig {
|
|
|
373
390
|
* @see `docs/architect/middleware-composition.md`
|
|
374
391
|
*/
|
|
375
392
|
middleware?: Middleware[];
|
|
393
|
+
/**
|
|
394
|
+
* Phase 18.κ — tRPC-like typed RPC endpoints.
|
|
395
|
+
*
|
|
396
|
+
* Keys become URL segments: `endpoints.posts` is served from
|
|
397
|
+
* `/api/rpc/posts/<method>`. Each value is a `defineRpc()` result —
|
|
398
|
+
* a tagged object carrying Zod input/output schemas and handler
|
|
399
|
+
* functions. The runtime dispatcher (`runtime/server.ts`) registers
|
|
400
|
+
* every declared endpoint at `startServer()` time and validates
|
|
401
|
+
* both request inputs and handler outputs against the schemas.
|
|
402
|
+
*
|
|
403
|
+
* Wire protocol: `POST /api/rpc/<name>/<method>` with JSON body
|
|
404
|
+
* `{ "input": <value> }`; returns `{ ok: true, data }` or
|
|
405
|
+
* `{ ok: false, error: { code, message, issues? } }`.
|
|
406
|
+
*
|
|
407
|
+
* Client: `createRpcClient<typeof postsRpc>({ baseUrl: "/api/rpc/posts" })`.
|
|
408
|
+
*
|
|
409
|
+
* @see `docs/architect/typed-rpc.md`
|
|
410
|
+
* @see `@mandujs/core/contract/rpc` for `defineRpc`.
|
|
411
|
+
* @see `@mandujs/core/client/rpc` for `createRpcClient`.
|
|
412
|
+
*/
|
|
413
|
+
rpc?: {
|
|
414
|
+
endpoints?: Record<string, RpcDefinition<RpcProcedureRecord>>;
|
|
415
|
+
};
|
|
416
|
+
/**
|
|
417
|
+
* Phase 18.λ — declarative cron job scheduler.
|
|
418
|
+
*
|
|
419
|
+
* `scheduler.jobs` is an array of {@link CronDef} objects (from
|
|
420
|
+
* `@mandujs/core/scheduler`). At `startServer()` boot time the runtime
|
|
421
|
+
* filters the set by `runOn === "bun"` (or `runOn` omitted) and registers
|
|
422
|
+
* each surviving job with `Bun.cron`. At build time, when
|
|
423
|
+
* `--target=workers` is set, the CLI filters by `runOn === "workers"` (or
|
|
424
|
+
* omitted) and emits the schedule strings into the generated
|
|
425
|
+
* `wrangler.toml` `[triggers] crons = [...]` block.
|
|
426
|
+
*
|
|
427
|
+
* Schedule strings are validated synchronously at `defineCron` time, so
|
|
428
|
+
* malformed crontabs fail boot instead of silently never firing.
|
|
429
|
+
*
|
|
430
|
+
* `scheduler.disabled` is an escape hatch for environments where cron
|
|
431
|
+
* should not fire (e.g., a read-only replica). Default: `false`.
|
|
432
|
+
*
|
|
433
|
+
* @see `docs/architect/cron-scheduler.md`
|
|
434
|
+
* @see `@mandujs/core/scheduler` for `defineCron`.
|
|
435
|
+
*/
|
|
436
|
+
scheduler?: {
|
|
437
|
+
jobs?: CronDef[];
|
|
438
|
+
disabled?: boolean;
|
|
439
|
+
};
|
|
440
|
+
/**
|
|
441
|
+
* Phase 18.μ — first-class internationalization.
|
|
442
|
+
*
|
|
443
|
+
* Declaring this block opts the project into the framework's built-in
|
|
444
|
+
* locale resolution + route synthesis. The CLI (`mandu build` /
|
|
445
|
+
* `mandu dev`) materializes per-locale route variants when
|
|
446
|
+
* `strategy === "path-prefix"` so a single `app/docs/page.tsx`
|
|
447
|
+
* serves `/en/docs`, `/ko/docs`, etc. without file duplication.
|
|
448
|
+
*
|
|
449
|
+
* At runtime, the server dispatcher attaches `ctx.locale`
|
|
450
|
+
* ({@link ResolvedLocale}) + `ctx.t` (typed translator, when a
|
|
451
|
+
* message registry is wired) to every loader and stamps
|
|
452
|
+
* `Vary: Accept-Language` on responses so CDNs cache correctly.
|
|
453
|
+
*
|
|
454
|
+
* Coexists with the legacy `app/[lang]/...` manual pattern — users
|
|
455
|
+
* migrate only when ready. See `docs/architect/i18n.md`.
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* ```ts
|
|
459
|
+
* export default {
|
|
460
|
+
* i18n: {
|
|
461
|
+
* locales: ['en', 'ko'],
|
|
462
|
+
* defaultLocale: 'en',
|
|
463
|
+
* strategy: 'path-prefix',
|
|
464
|
+
* },
|
|
465
|
+
* } satisfies ManduConfig;
|
|
466
|
+
* ```
|
|
467
|
+
*/
|
|
468
|
+
i18n?: {
|
|
469
|
+
/** Non-empty list of supported locale codes. */
|
|
470
|
+
locales: LocaleCode[];
|
|
471
|
+
/** Fallback when no signal matches. MUST be in `locales`. */
|
|
472
|
+
defaultLocale: LocaleCode;
|
|
473
|
+
/** Optional fallback chain between request locale and defaultLocale. */
|
|
474
|
+
fallback?: LocaleCode;
|
|
475
|
+
/** Locale detection strategy. See {@link I18nStrategy}. */
|
|
476
|
+
strategy: I18nStrategy;
|
|
477
|
+
/** Cookie name (default: "mandu_locale"). */
|
|
478
|
+
cookieName?: string;
|
|
479
|
+
/** Domain → locale map; required when strategy === "domain". */
|
|
480
|
+
domains?: Record<string, LocaleCode>;
|
|
481
|
+
};
|
|
376
482
|
}
|
|
377
483
|
|
|
378
484
|
export const CONFIG_FILES = [
|