orbit-rpc 0.1.0 → 1.0.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/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # Orbit RPC
2
+
3
+ Auto-converts `server.ts` functions to type-safe RPC endpoints — designed for the AI era.
4
+
5
+ > Part of the [Orbit](../../) frontend toolkit — designed so that AI-generated code and human-written code always look the same.
6
+
7
+ ## Features
8
+
9
+ - **Zero boilerplate** — Export a function from `server.ts`, it becomes an RPC endpoint
10
+ - **Automatic Zod validation** — Link a Zod schema in `schema.ts`, get runtime validation for free
11
+ - **Vite plugin** — Dev middleware with HMR, production build generates a Hono app
12
+ - **Cloudflare Workers ready** — `import app from "virtual:orbit-rpc/server"` and deploy
13
+ - **Type-safe end-to-end** — TypeScript types flow from schema to server to client
14
+
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ pnpm add orbit-rpc hono
19
+ ```
20
+
21
+ ```ts
22
+ // vite.config.ts
23
+ import { orbitRpc } from "orbit-rpc";
24
+
25
+ export default defineConfig({
26
+ plugins: [orbitRpc()],
27
+ });
28
+ ```
29
+
30
+ Write a server function:
31
+
32
+ ```ts
33
+ // src/routes/tasks/server.ts
34
+ export async function getTasks(signal?: AbortSignal) {
35
+ const res = await fetch("https://api.example.com/tasks", { signal });
36
+ return res.json();
37
+ }
38
+ ```
39
+
40
+ Import it from the client — the plugin replaces the import with an HTTP fetch stub:
41
+
42
+ ```ts
43
+ // src/routes/tasks/hooks.ts
44
+ import { getTasks } from "./server";
45
+
46
+ export function useTasks() {
47
+ return useQuery(["tasks"], getTasks);
48
+ }
49
+ ```
50
+
51
+ That's it. `getTasks()` on the client calls `POST /rpc/tasks/getTasks` under the hood.
52
+
53
+ ## How It Works
54
+
55
+ The Vite plugin does two things:
56
+
57
+ 1. **Client-side transform** — `server.ts` imports are replaced with `fetch()` stubs that call `POST /rpc/{route}/{function}`
58
+ 2. **Server-side handler** — Dev middleware (or production Hono app) receives those requests and executes the real function
59
+
60
+ ```
61
+ Client Server
62
+ ───── ──────
63
+ import { getTasks } server.ts (real function)
64
+ from "./server" ↑
65
+ │ │
66
+ ↓ │
67
+ fetch("/rpc/tasks/getTasks") ──→ getTasks()
68
+ ```
69
+
70
+ ## Automatic Zod Validation
71
+
72
+ When `schema.ts` sits next to `server.ts`, the plugin automatically validates function arguments using the linked Zod schema.
73
+
74
+ ```ts
75
+ // src/routes/bookmarks/schema.ts
76
+ import { z } from "zod";
77
+
78
+ export const bookmarkInputSchema = z.object({
79
+ url: z.string().url(),
80
+ title: z.string().min(1),
81
+ description: z.string(),
82
+ tags: z.array(z.string()),
83
+ });
84
+
85
+ export type BookmarkInput = z.infer<typeof bookmarkInputSchema>;
86
+ ```
87
+
88
+ ```ts
89
+ // src/routes/bookmarks/server.ts
90
+ import type { BookmarkInput } from "./schema";
91
+
92
+ export async function createBookmark(data: BookmarkInput) {
93
+ // `data` is already validated by Zod — invalid requests get a 400 response
94
+ return db.insert(bookmarks).values(data);
95
+ }
96
+ ```
97
+
98
+ The plugin detects `export type X = z.infer<typeof ySchema>` patterns in `schema.ts` and binds `ySchema` to the matching parameter type in `server.ts`. No manual wiring needed.
99
+
100
+ ### Validation errors
101
+
102
+ Invalid requests receive a `400` response with structured error messages:
103
+
104
+ ```json
105
+ {
106
+ "error": "Validation error on \"data\": url: Invalid url, title: String must contain at least 1 character(s)"
107
+ }
108
+ ```
109
+
110
+ ## Production Build
111
+
112
+ Import the virtual module to get a Hono app with all RPC routes pre-registered:
113
+
114
+ ```ts
115
+ // worker.ts (Cloudflare Workers entry)
116
+ import app from "virtual:orbit-rpc/server";
117
+
118
+ export default app;
119
+ ```
120
+
121
+ The generated Hono app includes:
122
+ - All `server.ts` functions as `POST` routes
123
+ - Zod validation (using `safeParse`) for validated parameters
124
+ - JSON parsing with error handling
125
+ - 1MB payload size limit
126
+
127
+ ## Plugin Options
128
+
129
+ ```ts
130
+ orbitRpc({
131
+ routesDir: "src/routes", // Where to scan for server.ts files (default: "src/routes")
132
+ rpcBase: "/rpc", // URL prefix for RPC endpoints (default: "/rpc")
133
+ });
134
+ ```
135
+
136
+ ## Type Flow
137
+
138
+ `schema.ts` is the single source of truth. One change propagates through all layers:
139
+
140
+ ```
141
+ schema.ts Define Zod schema + export type
142
+
143
+ ├─→ server.ts Function params use the type → auto-validated
144
+ ├─→ hooks.ts (useForm) Form binds to the same schema → client validation
145
+ └─→ hooks.ts (useQuery) Return type inferred from server function
146
+ ```
147
+
148
+ ## Conventions
149
+
150
+ - `server.ts` — Export `async function` or `export const fn = async () => {}`
151
+ - `schema.ts` — Export Zod schemas and `z.infer` types for automatic validation
152
+ - `AbortSignal` parameters are automatically detected and passed through from `fetch`
153
+ - Dynamic route segments (`[id]`) in directory names become part of the URL prefix
154
+
155
+ ## License
156
+
157
+ MIT
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 functions = extractExportedFunctions(filePath);
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
- * server.ts から export された関数名を抽出する。
32
- * 簡易パース(正規表現ベース)。
35
+ * schema.ts から「型名 Zod スキーマ名」のマップを抽出する。
36
+ *
37
+ * 認識するパターン:
38
+ * export type TaskForm = z.infer<typeof taskFormSchema>;
39
+ * → Map { "TaskForm" => "taskFormSchema" }
33
40
  */
34
- function extractExportedFunctions(filePath) {
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)) functions.push({ name: match[1] });
38
- for (const match of content.matchAll(/export\s+const\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)\s*=>|function\b)/g)) if (!functions.some((f) => f.name === match[1])) functions.push({ name: match[1] });
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]" ��� "/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
- if (!mod.functions.find((f) => f.name === functionName)) throw new RpcError(`Function not found: ${functionName}`, 404);
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
- return fn(...body ? JSON.parse(body) : []);
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("/").replace(/[\\"]/g, "\\$&");
351
+ lines.push(`import * as mod${i} from "${importPath}";`);
352
+ if (mod.schemaFilePath) {
353
+ const schemaPath = mod.schemaFilePath.split(path.sep).join("/").replace(/[\\"]/g, "\\$&");
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.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "RPC layer for Orbit — auto-converts server.ts functions to Hono routes",
5
5
  "keywords": [
6
- "rpc",
6
+ "fullstack",
7
7
  "hono",
8
- "vite",
9
8
  "react",
10
- "fullstack"
9
+ "rpc",
10
+ "vite"
11
11
  ],
12
12
  "license": "MIT",
13
13
  "author": "ashunar0",