orbit-rpc 0.1.0 → 0.2.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/dist/index.d.mts +0 -7
- package/dist/index.mjs +222 -12
- package/package.json +4 -4
package/dist/index.d.mts
CHANGED
|
@@ -7,13 +7,6 @@ interface OrbitRpcConfig {
|
|
|
7
7
|
/** RPC エンドポイントのプレフィックス(デフォルト: "/rpc") */
|
|
8
8
|
rpcBase?: string;
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
11
|
-
* Orbit RPC Vite プラグイン。
|
|
12
|
-
*
|
|
13
|
-
* 2つの仕事をする:
|
|
14
|
-
* 1. クライアント側: server.ts の import を HTTP fetch スタブに差し替え
|
|
15
|
-
* 2. dev サーバー: /rpc/* リクエストを受けて server.ts の関数を実行
|
|
16
|
-
*/
|
|
17
10
|
declare function orbitRpc(config?: OrbitRpcConfig): Plugin[];
|
|
18
11
|
//#endregion
|
|
19
12
|
export { type OrbitRpcConfig, orbitRpc };
|
package/dist/index.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import fs from "node:fs";
|
|
|
3
3
|
//#region src/scanner.ts
|
|
4
4
|
/**
|
|
5
5
|
* routes ディレクトリから server.ts ファイルをスキャンし、
|
|
6
|
-
*
|
|
6
|
+
* エクスポートされた関数名と引数の型情報を抽出する。
|
|
7
|
+
* 隣接する schema.ts があれば Zod スキーマとの対応も解決する。
|
|
7
8
|
*/
|
|
8
9
|
async function scanServerModules(root, routesDir) {
|
|
9
10
|
const absoluteRoutesDir = path.resolve(root, routesDir);
|
|
@@ -18,9 +19,12 @@ async function walk(dir, routesRoot, modules) {
|
|
|
18
19
|
if (serverFile) {
|
|
19
20
|
const filePath = path.join(dir, serverFile.name);
|
|
20
21
|
const routePrefix = dirToRoutePrefix(path.relative(routesRoot, dir));
|
|
21
|
-
const
|
|
22
|
+
const schemaPath = path.join(dir, "schema.ts");
|
|
23
|
+
const hasSchema = entries.some((e) => e.isFile() && e.name === "schema.ts");
|
|
24
|
+
const functions = extractExportedFunctions(filePath, hasSchema ? extractSchemaMap(schemaPath) : /* @__PURE__ */ new Map());
|
|
22
25
|
if (functions.length > 0) modules.push({
|
|
23
26
|
filePath,
|
|
27
|
+
schemaFilePath: hasSchema ? schemaPath : void 0,
|
|
24
28
|
routePrefix,
|
|
25
29
|
functions
|
|
26
30
|
});
|
|
@@ -28,21 +32,117 @@ async function walk(dir, routesRoot, modules) {
|
|
|
28
32
|
for (const entry of entries) if (entry.isDirectory() && !entry.name.startsWith("_")) await walk(path.join(dir, entry.name), routesRoot, modules);
|
|
29
33
|
}
|
|
30
34
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
35
|
+
* schema.ts から「型名 → Zod スキーマ名」のマップを抽出する。
|
|
36
|
+
*
|
|
37
|
+
* 認識するパターン:
|
|
38
|
+
* export type TaskForm = z.infer<typeof taskFormSchema>;
|
|
39
|
+
* → Map { "TaskForm" => "taskFormSchema" }
|
|
33
40
|
*/
|
|
34
|
-
function
|
|
41
|
+
function extractSchemaMap(filePath) {
|
|
42
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
43
|
+
const map = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const match of content.matchAll(/export\s+type\s+(\w+)\s*=\s*z\.infer\s*<\s*typeof\s+(\w+)\s*>/g)) map.set(match[1], match[2]);
|
|
45
|
+
return map;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* server.ts から export された関数を抽出する。
|
|
49
|
+
* 各引数の型名を解析し、schemaMap にマッチすれば schemaName を紐付ける。
|
|
50
|
+
*/
|
|
51
|
+
function extractExportedFunctions(filePath, schemaMap) {
|
|
35
52
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
36
53
|
const functions = [];
|
|
37
|
-
for (const match of content.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g))
|
|
38
|
-
|
|
54
|
+
for (const match of content.matchAll(/export\s+(?:async\s+)?function\s+(\w+)\s*\(/g)) {
|
|
55
|
+
const name = match[1];
|
|
56
|
+
if (functions.some((f) => f.name === name)) continue;
|
|
57
|
+
const params = parseParams(extractBalancedParens(content, match.index + match[0].length - 1), schemaMap);
|
|
58
|
+
functions.push({
|
|
59
|
+
name,
|
|
60
|
+
params
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
for (const match of content.matchAll(/export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(/g)) {
|
|
64
|
+
const name = match[1];
|
|
65
|
+
if (functions.some((f) => f.name === name)) continue;
|
|
66
|
+
const params = parseParams(extractBalancedParens(content, match.index + match[0].length - 1), schemaMap);
|
|
67
|
+
functions.push({
|
|
68
|
+
name,
|
|
69
|
+
params
|
|
70
|
+
});
|
|
71
|
+
}
|
|
39
72
|
return functions;
|
|
40
73
|
}
|
|
41
74
|
/**
|
|
75
|
+
* 開き括弧の位置から対応する閉じ括弧までの中身を返す。
|
|
76
|
+
* ネストされた括弧(デフォルト引数内の関数呼び出し等)に対応。
|
|
77
|
+
*/
|
|
78
|
+
function extractBalancedParens(content, openIndex) {
|
|
79
|
+
if (content[openIndex] !== "(") return "";
|
|
80
|
+
let depth = 0;
|
|
81
|
+
for (let i = openIndex; i < content.length; i++) if (content[i] === "(") depth++;
|
|
82
|
+
else if (content[i] === ")") {
|
|
83
|
+
depth--;
|
|
84
|
+
if (depth === 0) return content.slice(openIndex + 1, i);
|
|
85
|
+
}
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 関数の引数文字列をパースして FunctionParam 配列にする。
|
|
90
|
+
* デフォルト値内のカンマやネストに対応するため、括弧の深さを追跡する。
|
|
91
|
+
*
|
|
92
|
+
* 例: "input: TaskForm, signal?: AbortSignal"
|
|
93
|
+
* → [{ name: "input", typeName: "TaskForm", schemaName: "taskFormSchema" },
|
|
94
|
+
* { name: "signal", typeName: "AbortSignal" }]
|
|
95
|
+
*/
|
|
96
|
+
function parseParams(paramsStr, schemaMap) {
|
|
97
|
+
if (!paramsStr.trim()) return [];
|
|
98
|
+
const parts = splitTopLevelCommas(paramsStr);
|
|
99
|
+
const params = [];
|
|
100
|
+
for (const part of parts) {
|
|
101
|
+
const withoutDefault = part.replace(/\s*=\s*[\s\S]*$/, "").trim();
|
|
102
|
+
if (!withoutDefault) continue;
|
|
103
|
+
const paramMatch = withoutDefault.match(/^(\w+)\??\s*:\s*(\w+)/);
|
|
104
|
+
if (paramMatch) {
|
|
105
|
+
const name = paramMatch[1];
|
|
106
|
+
const typeName = paramMatch[2];
|
|
107
|
+
const schemaName = schemaMap.get(typeName);
|
|
108
|
+
params.push({
|
|
109
|
+
name,
|
|
110
|
+
typeName,
|
|
111
|
+
schemaName
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
const nameOnly = withoutDefault.match(/^(\w+)/);
|
|
115
|
+
if (nameOnly) params.push({ name: nameOnly[1] });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return params;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* トップレベルのカンマで文字列を分割する。
|
|
122
|
+
* 括弧 (), {}, <> 内のカンマは分割しない。
|
|
123
|
+
*/
|
|
124
|
+
function splitTopLevelCommas(str) {
|
|
125
|
+
const parts = [];
|
|
126
|
+
let current = "";
|
|
127
|
+
let depth = 0;
|
|
128
|
+
for (const ch of str) if (ch === "(" || ch === "{" || ch === "<") {
|
|
129
|
+
depth++;
|
|
130
|
+
current += ch;
|
|
131
|
+
} else if (ch === ")" || ch === "}" || ch === ">") {
|
|
132
|
+
depth--;
|
|
133
|
+
current += ch;
|
|
134
|
+
} else if (ch === "," && depth === 0) {
|
|
135
|
+
parts.push(current);
|
|
136
|
+
current = "";
|
|
137
|
+
} else current += ch;
|
|
138
|
+
if (current.trim()) parts.push(current);
|
|
139
|
+
return parts;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
42
142
|
* ディレクトリの相対パスをルートプレフィックスに変換する。
|
|
43
143
|
* "" → ""
|
|
44
144
|
* "tasks" → "/tasks"
|
|
45
|
-
* "users/[id]"
|
|
145
|
+
* "users/[id]" → "/users/:id"
|
|
46
146
|
*/
|
|
47
147
|
function dirToRoutePrefix(relativePath) {
|
|
48
148
|
if (relativePath === "") return "";
|
|
@@ -60,12 +160,17 @@ function dirToRoutePrefix(relativePath) {
|
|
|
60
160
|
* 2つの仕事をする:
|
|
61
161
|
* 1. クライアント側: server.ts の import を HTTP fetch スタブに差し替え
|
|
62
162
|
* 2. dev サーバー: /rpc/* リクエストを受けて server.ts の関数を実行
|
|
163
|
+
*
|
|
164
|
+
* schema.ts に Zod スキーマがあれば、自動でバリデーションを適用する。
|
|
63
165
|
*/
|
|
166
|
+
const VIRTUAL_SERVER_ID = "virtual:orbit-rpc/server";
|
|
167
|
+
const RESOLVED_VIRTUAL_SERVER_ID = `\0${VIRTUAL_SERVER_ID}`;
|
|
64
168
|
function orbitRpc(config = {}) {
|
|
65
169
|
const routesDir = config.routesDir ?? "src/routes";
|
|
66
170
|
const rpcBase = config.rpcBase ?? "/rpc";
|
|
67
171
|
let root;
|
|
68
172
|
let serverModules = [];
|
|
173
|
+
let scanned = false;
|
|
69
174
|
return [{
|
|
70
175
|
name: "orbit-rpc:transform",
|
|
71
176
|
configResolved(resolvedConfig) {
|
|
@@ -73,6 +178,16 @@ function orbitRpc(config = {}) {
|
|
|
73
178
|
},
|
|
74
179
|
async buildStart() {
|
|
75
180
|
serverModules = await scanServerModules(root, routesDir);
|
|
181
|
+
scanned = true;
|
|
182
|
+
},
|
|
183
|
+
resolveId(id) {
|
|
184
|
+
if (id === VIRTUAL_SERVER_ID) return RESOLVED_VIRTUAL_SERVER_ID;
|
|
185
|
+
},
|
|
186
|
+
async load(id) {
|
|
187
|
+
if (id === RESOLVED_VIRTUAL_SERVER_ID) {
|
|
188
|
+
if (!scanned) serverModules = await scanServerModules(root, routesDir);
|
|
189
|
+
return generateHonoApp(serverModules, root, rpcBase);
|
|
190
|
+
}
|
|
76
191
|
},
|
|
77
192
|
async transform(code, id) {
|
|
78
193
|
if (!id.endsWith("/server.ts")) return null;
|
|
@@ -94,10 +209,11 @@ function orbitRpc(config = {}) {
|
|
|
94
209
|
const routesPath = path.resolve(root, routesDir);
|
|
95
210
|
const onFileChange = async (file) => {
|
|
96
211
|
if (!file.startsWith(routesPath)) return;
|
|
97
|
-
if (!file.endsWith("/server.ts")) return;
|
|
212
|
+
if (!file.endsWith("/server.ts") && !file.endsWith("/schema.ts")) return;
|
|
98
213
|
serverModules = await scanServerModules(root, routesDir);
|
|
99
214
|
};
|
|
100
215
|
server.watcher.on("add", onFileChange);
|
|
216
|
+
server.watcher.on("change", onFileChange);
|
|
101
217
|
server.watcher.on("unlink", onFileChange);
|
|
102
218
|
server.middlewares.use(async (req, res, next) => {
|
|
103
219
|
if (!req.url?.startsWith(rpcBase)) return next();
|
|
@@ -162,6 +278,8 @@ var RpcError = class extends Error {
|
|
|
162
278
|
*
|
|
163
279
|
* URL: POST /rpc/{routePrefix}/{functionName}
|
|
164
280
|
* Body: JSON(関数の引数)
|
|
281
|
+
*
|
|
282
|
+
* schema.ts に Zod スキーマがあれば、引数をバリデーションしてから関数を実行する。
|
|
165
283
|
*/
|
|
166
284
|
async function handleRpcRequest(server, req, rpcBase, modules) {
|
|
167
285
|
if (req.method !== "POST") throw new RpcError("Method not allowed", 405);
|
|
@@ -172,11 +290,103 @@ async function handleRpcRequest(server, req, rpcBase, modules) {
|
|
|
172
290
|
const functionName = decodeURIComponent(rpcPath.slice(lastSlash + 1));
|
|
173
291
|
const mod = modules.find((m) => m.routePrefix === routePrefix);
|
|
174
292
|
if (!mod) throw new RpcError(`Module not found: ${routePrefix}`, 404);
|
|
175
|
-
|
|
293
|
+
const fnDef = mod.functions.find((f) => f.name === functionName);
|
|
294
|
+
if (!fnDef) throw new RpcError(`Function not found: ${functionName}`, 404);
|
|
176
295
|
const fn = (await server.ssrLoadModule(mod.filePath))[functionName];
|
|
177
296
|
if (typeof fn !== "function") throw new RpcError(`${functionName} is not a function`, 500);
|
|
178
297
|
const body = await readBody(req);
|
|
179
|
-
|
|
298
|
+
let args;
|
|
299
|
+
try {
|
|
300
|
+
args = body ? JSON.parse(body) : [];
|
|
301
|
+
} catch {
|
|
302
|
+
throw new RpcError("Invalid JSON in request body", 400);
|
|
303
|
+
}
|
|
304
|
+
if (!Array.isArray(args)) throw new RpcError("Request body must be a JSON array", 400);
|
|
305
|
+
return fn(...await validateArgs(args, fnDef, mod, server));
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* 各引数を対応する Zod スキーマでバリデーションする。
|
|
309
|
+
* スキーマが紐付いていない引数はそのまま通す。
|
|
310
|
+
* 引数が不足していてもスキーマがあればバリデーションを実行する(Zod が required エラーを出す)。
|
|
311
|
+
*/
|
|
312
|
+
async function validateArgs(args, fnDef, mod, server) {
|
|
313
|
+
if (!fnDef.params.some((p) => p.schemaName) || !mod.schemaFilePath) return args;
|
|
314
|
+
const schemaModule = await server.ssrLoadModule(mod.schemaFilePath);
|
|
315
|
+
const validated = [...args];
|
|
316
|
+
for (let i = 0; i < fnDef.params.length; i++) {
|
|
317
|
+
const param = fnDef.params[i];
|
|
318
|
+
if (!param.schemaName) continue;
|
|
319
|
+
const schema = schemaModule[param.schemaName];
|
|
320
|
+
if (schema && typeof schema.safeParse === "function") {
|
|
321
|
+
const result = schema.safeParse(args[i]);
|
|
322
|
+
if (!result.success) {
|
|
323
|
+
const issues = result.error.issues.map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join(", ");
|
|
324
|
+
throw new RpcError(`Validation error on "${param.name}": ${issues}`, 400);
|
|
325
|
+
}
|
|
326
|
+
validated[i] = result.data;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return validated;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* 本番用 Hono アプリのコードを生成する。
|
|
333
|
+
*
|
|
334
|
+
* import app from "virtual:orbit-rpc/server" で使える。
|
|
335
|
+
* Cloudflare Workers の場合は export default app; するだけ。
|
|
336
|
+
*
|
|
337
|
+
* schema.ts に Zod スキーマがあれば、.parse() によるバリデーションを含む。
|
|
338
|
+
*/
|
|
339
|
+
function generateHonoApp(modules, root, rpcBase) {
|
|
340
|
+
const lines = [];
|
|
341
|
+
lines.push(`import { Hono } from "hono";`);
|
|
342
|
+
lines.push(``);
|
|
343
|
+
const validModules = [];
|
|
344
|
+
for (const [i, mod] of modules.entries()) {
|
|
345
|
+
if (mod.routePrefix.includes(":")) {
|
|
346
|
+
console.warn(`[orbit-rpc] Dynamic route prefix "${mod.routePrefix}" is not supported for RPC. Skipping.`);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
validModules.push([i, mod]);
|
|
350
|
+
const importPath = mod.filePath.split(path.sep).join("/");
|
|
351
|
+
lines.push(`import * as mod${i} from "${importPath}";`);
|
|
352
|
+
if (mod.schemaFilePath) {
|
|
353
|
+
const schemaPath = mod.schemaFilePath.split(path.sep).join("/");
|
|
354
|
+
lines.push(`import * as schema${i} from "${schemaPath}";`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
lines.push(``);
|
|
358
|
+
lines.push(`const app = new Hono();`);
|
|
359
|
+
lines.push(``);
|
|
360
|
+
for (const [i, mod] of validModules) for (const fn of mod.functions) {
|
|
361
|
+
const endpoint = `${rpcBase}${mod.routePrefix}/${fn.name}`;
|
|
362
|
+
lines.push(`app.post("${endpoint}", async (c) => {`);
|
|
363
|
+
lines.push(` try {`);
|
|
364
|
+
lines.push(` const body = await c.req.text();`);
|
|
365
|
+
lines.push(` if (body.length > 1048576) return c.json({ error: "Payload too large" }, 413);`);
|
|
366
|
+
lines.push(` let args;`);
|
|
367
|
+
lines.push(` try { args = body ? JSON.parse(body) : []; } catch { return c.json({ error: "Invalid JSON in request body" }, 400); }`);
|
|
368
|
+
lines.push(` if (!Array.isArray(args)) return c.json({ error: "Request body must be a JSON array" }, 400);`);
|
|
369
|
+
if (mod.schemaFilePath) for (let pi = 0; pi < fn.params.length; pi++) {
|
|
370
|
+
const param = fn.params[pi];
|
|
371
|
+
if (param.schemaName) {
|
|
372
|
+
lines.push(` {`);
|
|
373
|
+
lines.push(` const r = schema${i}.${param.schemaName}.safeParse(args[${pi}]);`);
|
|
374
|
+
lines.push(` if (!r.success) { const msg = r.error.issues.map(i => i.path.length ? i.path.join(".") + ": " + i.message : i.message).join(", "); return c.json({ error: "Validation error on \\"${param.name}\\": " + msg }, 400); }`);
|
|
375
|
+
lines.push(` args[${pi}] = r.data;`);
|
|
376
|
+
lines.push(` }`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
lines.push(` const result = await mod${i}.${fn.name}(...args);`);
|
|
380
|
+
lines.push(` return c.json(result ?? null);`);
|
|
381
|
+
lines.push(` } catch (err) {`);
|
|
382
|
+
lines.push(` console.error(err);`);
|
|
383
|
+
lines.push(` return c.json({ error: "Internal Server Error" }, 500);`);
|
|
384
|
+
lines.push(` }`);
|
|
385
|
+
lines.push(`});`);
|
|
386
|
+
lines.push(``);
|
|
387
|
+
}
|
|
388
|
+
lines.push(`export default app;`);
|
|
389
|
+
return lines.join("\n");
|
|
180
390
|
}
|
|
181
391
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
182
392
|
function readBody(req) {
|
|
@@ -186,7 +396,7 @@ function readBody(req) {
|
|
|
186
396
|
req.on("data", (chunk) => {
|
|
187
397
|
size += chunk.length;
|
|
188
398
|
if (size > MAX_BODY_SIZE) {
|
|
189
|
-
req.destroy();
|
|
399
|
+
req.destroy?.();
|
|
190
400
|
reject(new RpcError("Request body too large", 413));
|
|
191
401
|
return;
|
|
192
402
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orbit-rpc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "RPC layer for Orbit — auto-converts server.ts functions to Hono routes",
|
|
5
5
|
"keywords": [
|
|
6
|
-
"
|
|
6
|
+
"fullstack",
|
|
7
7
|
"hono",
|
|
8
|
-
"vite",
|
|
9
8
|
"react",
|
|
10
|
-
"
|
|
9
|
+
"rpc",
|
|
10
|
+
"vite"
|
|
11
11
|
],
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"author": "ashunar0",
|