@mandujs/core 0.30.0 → 0.31.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 +4 -1
- package/src/client/index.ts +11 -1
- package/src/client/rpc.ts +293 -140
- package/src/config/mandu.ts +49 -0
- package/src/config/validate.ts +48 -0
- package/src/contract/index.ts +18 -0
- package/src/contract/rpc.ts +443 -0
- package/src/middleware/index.ts +7 -0
- package/src/middleware/scheduler-cron.ts +96 -0
- package/src/runtime/server.ts +155 -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.31.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",
|
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,8 @@ 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";
|
|
6
8
|
|
|
7
9
|
export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
|
|
8
10
|
|
|
@@ -373,6 +375,53 @@ export interface ManduConfig {
|
|
|
373
375
|
* @see `docs/architect/middleware-composition.md`
|
|
374
376
|
*/
|
|
375
377
|
middleware?: Middleware[];
|
|
378
|
+
/**
|
|
379
|
+
* Phase 18.κ — tRPC-like typed RPC endpoints.
|
|
380
|
+
*
|
|
381
|
+
* Keys become URL segments: `endpoints.posts` is served from
|
|
382
|
+
* `/api/rpc/posts/<method>`. Each value is a `defineRpc()` result —
|
|
383
|
+
* a tagged object carrying Zod input/output schemas and handler
|
|
384
|
+
* functions. The runtime dispatcher (`runtime/server.ts`) registers
|
|
385
|
+
* every declared endpoint at `startServer()` time and validates
|
|
386
|
+
* both request inputs and handler outputs against the schemas.
|
|
387
|
+
*
|
|
388
|
+
* Wire protocol: `POST /api/rpc/<name>/<method>` with JSON body
|
|
389
|
+
* `{ "input": <value> }`; returns `{ ok: true, data }` or
|
|
390
|
+
* `{ ok: false, error: { code, message, issues? } }`.
|
|
391
|
+
*
|
|
392
|
+
* Client: `createRpcClient<typeof postsRpc>({ baseUrl: "/api/rpc/posts" })`.
|
|
393
|
+
*
|
|
394
|
+
* @see `docs/architect/typed-rpc.md`
|
|
395
|
+
* @see `@mandujs/core/contract/rpc` for `defineRpc`.
|
|
396
|
+
* @see `@mandujs/core/client/rpc` for `createRpcClient`.
|
|
397
|
+
*/
|
|
398
|
+
rpc?: {
|
|
399
|
+
endpoints?: Record<string, RpcDefinition<RpcProcedureRecord>>;
|
|
400
|
+
};
|
|
401
|
+
/**
|
|
402
|
+
* Phase 18.λ — declarative cron job scheduler.
|
|
403
|
+
*
|
|
404
|
+
* `scheduler.jobs` is an array of {@link CronDef} objects (from
|
|
405
|
+
* `@mandujs/core/scheduler`). At `startServer()` boot time the runtime
|
|
406
|
+
* filters the set by `runOn === "bun"` (or `runOn` omitted) and registers
|
|
407
|
+
* each surviving job with `Bun.cron`. At build time, when
|
|
408
|
+
* `--target=workers` is set, the CLI filters by `runOn === "workers"` (or
|
|
409
|
+
* omitted) and emits the schedule strings into the generated
|
|
410
|
+
* `wrangler.toml` `[triggers] crons = [...]` block.
|
|
411
|
+
*
|
|
412
|
+
* Schedule strings are validated synchronously at `defineCron` time, so
|
|
413
|
+
* malformed crontabs fail boot instead of silently never firing.
|
|
414
|
+
*
|
|
415
|
+
* `scheduler.disabled` is an escape hatch for environments where cron
|
|
416
|
+
* should not fire (e.g., a read-only replica). Default: `false`.
|
|
417
|
+
*
|
|
418
|
+
* @see `docs/architect/cron-scheduler.md`
|
|
419
|
+
* @see `@mandujs/core/scheduler` for `defineCron`.
|
|
420
|
+
*/
|
|
421
|
+
scheduler?: {
|
|
422
|
+
jobs?: CronDef[];
|
|
423
|
+
disabled?: boolean;
|
|
424
|
+
};
|
|
376
425
|
}
|
|
377
426
|
|
|
378
427
|
export const CONFIG_FILES = [
|
package/src/config/validate.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { readJsonFile } from "../utils/bun";
|
|
|
6
6
|
import type { ManduAdapter } from "../runtime/adapter";
|
|
7
7
|
import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
|
|
8
8
|
import type { Middleware } from "../middleware/define";
|
|
9
|
+
import type { CronDef } from "../scheduler";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* DNA-003: Strict mode schema helper
|
|
@@ -335,6 +336,47 @@ const CacheConfigSchema = z
|
|
|
335
336
|
})
|
|
336
337
|
.strict();
|
|
337
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Phase 18.λ — declarative cron scheduler (strict).
|
|
341
|
+
*
|
|
342
|
+
* Each `jobs[i]` entry is structurally validated: `name` must be a non-empty
|
|
343
|
+
* string, `schedule` a string (deeper cron validation runs at `defineCron`
|
|
344
|
+
* time), and `handler` (or `run` alias) a function. Zod cannot introspect
|
|
345
|
+
* closures, so the handler field is a structural check.
|
|
346
|
+
*
|
|
347
|
+
* `disabled` short-circuits registration in environments where cron
|
|
348
|
+
* shouldn't fire (e.g., a read-only replica reading the same config file
|
|
349
|
+
* its primary uses).
|
|
350
|
+
*/
|
|
351
|
+
const CronDefSchema = z.custom<CronDef>(
|
|
352
|
+
(v) => {
|
|
353
|
+
if (typeof v !== "object" || v === null) return false;
|
|
354
|
+
const obj = v as Record<string, unknown>;
|
|
355
|
+
if (typeof obj.name !== "string" || obj.name.length === 0) return false;
|
|
356
|
+
if (typeof obj.schedule !== "string" || obj.schedule.length === 0) return false;
|
|
357
|
+
if (typeof obj.handler !== "function" && typeof obj.run !== "function") return false;
|
|
358
|
+
if (obj.timezone !== undefined && typeof obj.timezone !== "string") return false;
|
|
359
|
+
if (obj.runOn !== undefined) {
|
|
360
|
+
if (!Array.isArray(obj.runOn)) return false;
|
|
361
|
+
for (const r of obj.runOn) {
|
|
362
|
+
if (r !== "bun" && r !== "workers") return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return true;
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
message:
|
|
369
|
+
"Each cron job must be an object with `name` (string), `schedule` (string), and `handler` (function). Optional: `timezone` (string), `runOn` (array of 'bun'|'workers'), `skipInDev` (boolean), `timeoutMs` (number).",
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const SchedulerConfigSchema = z
|
|
374
|
+
.object({
|
|
375
|
+
jobs: z.array(CronDefSchema).optional(),
|
|
376
|
+
disabled: z.boolean().optional(),
|
|
377
|
+
})
|
|
378
|
+
.strict();
|
|
379
|
+
|
|
338
380
|
export const ManduConfigSchema = z
|
|
339
381
|
.object({
|
|
340
382
|
adapter: AdapterConfigSchema.optional(),
|
|
@@ -375,6 +417,12 @@ export const ManduConfigSchema = z
|
|
|
375
417
|
* passthrough at runtime).
|
|
376
418
|
*/
|
|
377
419
|
middleware: z.array(MiddlewareSchema).optional(),
|
|
420
|
+
/**
|
|
421
|
+
* Phase 18.λ — declarative cron scheduler. See {@link SchedulerConfigSchema}.
|
|
422
|
+
* Omit the block to disable scheduling entirely (zero-overhead
|
|
423
|
+
* passthrough).
|
|
424
|
+
*/
|
|
425
|
+
scheduler: SchedulerConfigSchema.optional(),
|
|
378
426
|
})
|
|
379
427
|
.strict();
|
|
380
428
|
|
package/src/contract/index.ts
CHANGED
|
@@ -19,6 +19,24 @@ export * from "./client-safe";
|
|
|
19
19
|
export * from "./protection";
|
|
20
20
|
export * from "./route-helpers";
|
|
21
21
|
|
|
22
|
+
// Phase 18.κ — tRPC-like typed RPC (see `./rpc.ts`).
|
|
23
|
+
export {
|
|
24
|
+
defineRpc,
|
|
25
|
+
registerRpc,
|
|
26
|
+
getRpc,
|
|
27
|
+
clearRpcRegistry,
|
|
28
|
+
listRpcEndpoints,
|
|
29
|
+
matchRpcPath,
|
|
30
|
+
dispatchRpc,
|
|
31
|
+
type RpcContext,
|
|
32
|
+
type RpcProcedure,
|
|
33
|
+
type RpcProcedureRecord,
|
|
34
|
+
type RpcDefinition,
|
|
35
|
+
type RpcClient,
|
|
36
|
+
type RpcWireEnvelope,
|
|
37
|
+
type RpcWireError,
|
|
38
|
+
} from "./rpc";
|
|
39
|
+
|
|
22
40
|
import type { ContractDefinition, ContractInstance, ContractSchema } from "./schema";
|
|
23
41
|
import type { ContractHandlers, RouteDefinition } from "./handler";
|
|
24
42
|
import { defineHandler, defineRoute } from "./handler";
|