orbit-rpc 0.2.0 → 1.1.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
@@ -1,3 +1,5 @@
1
+ import { Context } from "hono";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
1
3
  import { Plugin } from "vite";
2
4
 
3
5
  //#region src/plugin.d.ts
@@ -9,4 +11,33 @@ interface OrbitRpcConfig {
9
11
  }
10
12
  declare function orbitRpc(config?: OrbitRpcConfig): Plugin[];
11
13
  //#endregion
12
- export { type OrbitRpcConfig, orbitRpc };
14
+ //#region src/context.d.ts
15
+ declare const contextStorage: AsyncLocalStorage<Context<any, any, {}>>;
16
+ /**
17
+ * RPC ハンドラ内で Hono Context を取得する。
18
+ *
19
+ * server.ts の関数内から呼ぶと、現在のリクエストに紐づく Hono Context が返る。
20
+ * Cloudflare Workers のバインディング(D1, KV 等)や Cookie へのアクセスに使う。
21
+ *
22
+ * プロジェクト側で型付きラッパーを作って使う:
23
+ * ```ts
24
+ * // src/lib/context.ts
25
+ * import { getContext } from "orbit-rpc";
26
+ * type Bindings = { DB: D1Database };
27
+ * export const ctx = () => getContext<Bindings>();
28
+ * ```
29
+ *
30
+ * ```ts
31
+ * // routes/articles/server.ts
32
+ * import { ctx } from "../../lib/context";
33
+ * export async function getArticles() {
34
+ * const c = ctx();
35
+ * return c.env.DB.prepare("SELECT * FROM articles").all();
36
+ * }
37
+ * ```
38
+ */
39
+ declare function getContext<E extends Record<string, unknown> = Record<string, unknown>>(): Context<{
40
+ Bindings: E;
41
+ }>;
42
+ //#endregion
43
+ export { type OrbitRpcConfig, contextStorage, getContext, orbitRpc };
package/dist/index.mjs CHANGED
@@ -1,5 +1,39 @@
1
1
  import path from "node:path";
2
+ import { Readable } from "node:stream";
3
+ import { Hono } from "hono";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
2
5
  import fs from "node:fs";
6
+ //#region src/context.ts
7
+ const contextStorage = new AsyncLocalStorage();
8
+ /**
9
+ * RPC ハンドラ内で Hono Context を取得する。
10
+ *
11
+ * server.ts の関数内から呼ぶと、現在のリクエストに紐づく Hono Context が返る。
12
+ * Cloudflare Workers のバインディング(D1, KV 等)や Cookie へのアクセスに使う。
13
+ *
14
+ * プロジェクト側で型付きラッパーを作って使う:
15
+ * ```ts
16
+ * // src/lib/context.ts
17
+ * import { getContext } from "orbit-rpc";
18
+ * type Bindings = { DB: D1Database };
19
+ * export const ctx = () => getContext<Bindings>();
20
+ * ```
21
+ *
22
+ * ```ts
23
+ * // routes/articles/server.ts
24
+ * import { ctx } from "../../lib/context";
25
+ * export async function getArticles() {
26
+ * const c = ctx();
27
+ * return c.env.DB.prepare("SELECT * FROM articles").all();
28
+ * }
29
+ * ```
30
+ */
31
+ function getContext() {
32
+ const ctx = contextStorage.getStore();
33
+ if (!ctx) throw new Error("getContext() must be called within an RPC handler. Make sure you are calling it inside a function exported from server.ts.");
34
+ return ctx;
35
+ }
36
+ //#endregion
3
37
  //#region src/scanner.ts
4
38
  /**
5
39
  * routes ディレクトリから server.ts ファイルをスキャンし、
@@ -159,7 +193,7 @@ function dirToRoutePrefix(relativePath) {
159
193
  *
160
194
  * 2つの仕事をする:
161
195
  * 1. クライアント側: server.ts の import を HTTP fetch スタブに差し替え
162
- * 2. dev サーバー: /rpc/* リクエストを受けて server.ts の関数を実行
196
+ * 2. dev サーバー / 本番ビルド: Hono アプリで RPC リクエストを処理
163
197
  *
164
198
  * schema.ts に Zod スキーマがあれば、自動でバリデーションを適用する。
165
199
  */
@@ -215,17 +249,18 @@ function orbitRpc(config = {}) {
215
249
  server.watcher.on("add", onFileChange);
216
250
  server.watcher.on("change", onFileChange);
217
251
  server.watcher.on("unlink", onFileChange);
252
+ const devApp = createDevRpcApp(server, rpcBase, () => serverModules);
218
253
  server.middlewares.use(async (req, res, next) => {
219
254
  if (!req.url?.startsWith(rpcBase)) return next();
220
255
  try {
221
- const result = await handleRpcRequest(server, req, rpcBase, serverModules);
222
- res.setHeader("Content-Type", "application/json");
223
- res.end(JSON.stringify(result ?? null));
256
+ const webReq = toWebRequest(req);
257
+ await writeWebResponse(await devApp.fetch(webReq), res);
224
258
  } catch (err) {
225
- const message = err instanceof Error ? err.message : "Internal Server Error";
226
- res.statusCode = err instanceof RpcError ? err.status : 500;
227
- res.setHeader("Content-Type", "application/json");
228
- res.end(JSON.stringify({ error: message }));
259
+ if (!res.headersSent) {
260
+ res.statusCode = 500;
261
+ res.setHeader("Content-Type", "application/json");
262
+ res.end(JSON.stringify({ error: "Internal Server Error" }));
263
+ }
229
264
  }
230
265
  });
231
266
  }
@@ -267,51 +302,50 @@ function generateClientStub(mod, rpcBase) {
267
302
  }
268
303
  return lines.join("\n");
269
304
  }
270
- var RpcError = class extends Error {
271
- constructor(message, status) {
272
- super(message);
273
- this.status = status;
274
- }
275
- };
276
- /**
277
- * dev サーバーで RPC リクエストを処理する。
278
- *
279
- * URL: POST /rpc/{routePrefix}/{functionName}
280
- * Body: JSON(関数の引数)
281
- *
282
- * schema.ts Zod スキーマがあれば、引数をバリデーションしてから関数を実行する。
283
- */
284
- async function handleRpcRequest(server, req, rpcBase, modules) {
285
- if (req.method !== "POST") throw new RpcError("Method not allowed", 405);
286
- const rpcPath = req.url.slice(rpcBase.length);
287
- const lastSlash = rpcPath.lastIndexOf("/");
288
- if (lastSlash === -1) throw new RpcError("Invalid RPC path", 400);
289
- const routePrefix = rpcPath.slice(0, lastSlash) || "";
290
- const functionName = decodeURIComponent(rpcPath.slice(lastSlash + 1));
291
- const mod = modules.find((m) => m.routePrefix === routePrefix);
292
- if (!mod) throw new RpcError(`Module not found: ${routePrefix}`, 404);
293
- const fnDef = mod.functions.find((f) => f.name === functionName);
294
- if (!fnDef) throw new RpcError(`Function not found: ${functionName}`, 404);
295
- const fn = (await server.ssrLoadModule(mod.filePath))[functionName];
296
- if (typeof fn !== "function") throw new RpcError(`${functionName} is not a function`, 500);
297
- const body = await readBody(req);
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));
305
+ function createDevRpcApp(server, rpcBase, getModules) {
306
+ const app = new Hono();
307
+ app.post(`${rpcBase}/*`, async (c) => {
308
+ const rpcPath = c.req.path.slice(rpcBase.length);
309
+ const lastSlash = rpcPath.lastIndexOf("/");
310
+ if (lastSlash === -1) return c.json({ error: "Invalid RPC path" }, 400);
311
+ const routePrefix = rpcPath.slice(0, lastSlash) || "";
312
+ const functionName = decodeURIComponent(rpcPath.slice(lastSlash + 1));
313
+ const mod = getModules().find((m) => m.routePrefix === routePrefix);
314
+ if (!mod) return c.json({ error: `Module not found: ${routePrefix}` }, 404);
315
+ const fnDef = mod.functions.find((f) => f.name === functionName);
316
+ if (!fnDef) return c.json({ error: `Function not found: ${functionName}` }, 404);
317
+ const fn = (await server.ssrLoadModule(mod.filePath))[functionName];
318
+ if (typeof fn !== "function") return c.json({ error: `${functionName} is not a function` }, 500);
319
+ if (Number(c.req.header("content-length") ?? 0) > 1048576) return c.json({ error: "Payload too large" }, 413);
320
+ const body = await c.req.text();
321
+ if (body.length > 1048576) return c.json({ error: "Payload too large" }, 413);
322
+ let args;
323
+ try {
324
+ args = body ? JSON.parse(body) : [];
325
+ } catch {
326
+ return c.json({ error: "Invalid JSON in request body" }, 400);
327
+ }
328
+ if (!Array.isArray(args)) return c.json({ error: "Request body must be a JSON array" }, 400);
329
+ let validatedArgs;
330
+ try {
331
+ validatedArgs = await validateArgs(args, fnDef, mod, (p) => server.ssrLoadModule(p));
332
+ } catch (err) {
333
+ if (err instanceof RpcValidationError) return c.json({ error: err.message }, 400);
334
+ throw err;
335
+ }
336
+ const result = await contextStorage.run(c, () => fn(...validatedArgs));
337
+ return c.json(result ?? null);
338
+ });
339
+ app.all(`${rpcBase}/*`, (c) => c.json({ error: "Method not allowed" }, 405));
340
+ return app;
306
341
  }
307
342
  /**
308
343
  * 各引数を対応する Zod スキーマでバリデーションする。
309
344
  * スキーマが紐付いていない引数はそのまま通す。
310
- * 引数が不足していてもスキーマがあればバリデーションを実行する(Zod が required エラーを出す)。
311
345
  */
312
- async function validateArgs(args, fnDef, mod, server) {
346
+ async function validateArgs(args, fnDef, mod, loadModule) {
313
347
  if (!fnDef.params.some((p) => p.schemaName) || !mod.schemaFilePath) return args;
314
- const schemaModule = await server.ssrLoadModule(mod.schemaFilePath);
348
+ const schemaModule = await loadModule(mod.schemaFilePath);
315
349
  const validated = [...args];
316
350
  for (let i = 0; i < fnDef.params.length; i++) {
317
351
  const param = fnDef.params[i];
@@ -321,13 +355,18 @@ async function validateArgs(args, fnDef, mod, server) {
321
355
  const result = schema.safeParse(args[i]);
322
356
  if (!result.success) {
323
357
  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);
358
+ throw new RpcValidationError(`Validation error on "${param.name}": ${issues}`);
325
359
  }
326
360
  validated[i] = result.data;
327
361
  }
328
362
  }
329
363
  return validated;
330
364
  }
365
+ var RpcValidationError = class extends Error {
366
+ constructor(message) {
367
+ super(message);
368
+ }
369
+ };
331
370
  /**
332
371
  * 本番用 Hono アプリのコードを生成する。
333
372
  *
@@ -339,6 +378,7 @@ async function validateArgs(args, fnDef, mod, server) {
339
378
  function generateHonoApp(modules, root, rpcBase) {
340
379
  const lines = [];
341
380
  lines.push(`import { Hono } from "hono";`);
381
+ lines.push(`import { contextStorage } from "orbit-rpc";`);
342
382
  lines.push(``);
343
383
  const validModules = [];
344
384
  for (const [i, mod] of modules.entries()) {
@@ -347,10 +387,10 @@ function generateHonoApp(modules, root, rpcBase) {
347
387
  continue;
348
388
  }
349
389
  validModules.push([i, mod]);
350
- const importPath = mod.filePath.split(path.sep).join("/");
390
+ const importPath = mod.filePath.split(path.sep).join("/").replace(/[\\"]/g, "\\$&");
351
391
  lines.push(`import * as mod${i} from "${importPath}";`);
352
392
  if (mod.schemaFilePath) {
353
- const schemaPath = mod.schemaFilePath.split(path.sep).join("/");
393
+ const schemaPath = mod.schemaFilePath.split(path.sep).join("/").replace(/[\\"]/g, "\\$&");
354
394
  lines.push(`import * as schema${i} from "${schemaPath}";`);
355
395
  }
356
396
  }
@@ -361,6 +401,8 @@ function generateHonoApp(modules, root, rpcBase) {
361
401
  const endpoint = `${rpcBase}${mod.routePrefix}/${fn.name}`;
362
402
  lines.push(`app.post("${endpoint}", async (c) => {`);
363
403
  lines.push(` try {`);
404
+ lines.push(` const cl = Number(c.req.header("content-length") ?? 0);`);
405
+ lines.push(` if (cl > 1048576) return c.json({ error: "Payload too large" }, 413);`);
364
406
  lines.push(` const body = await c.req.text();`);
365
407
  lines.push(` if (body.length > 1048576) return c.json({ error: "Payload too large" }, 413);`);
366
408
  lines.push(` let args;`);
@@ -376,7 +418,7 @@ function generateHonoApp(modules, root, rpcBase) {
376
418
  lines.push(` }`);
377
419
  }
378
420
  }
379
- lines.push(` const result = await mod${i}.${fn.name}(...args);`);
421
+ lines.push(` const result = await contextStorage.run(c, () => mod${i}.${fn.name}(...args));`);
380
422
  lines.push(` return c.json(result ?? null);`);
381
423
  lines.push(` } catch (err) {`);
382
424
  lines.push(` console.error(err);`);
@@ -388,23 +430,41 @@ function generateHonoApp(modules, root, rpcBase) {
388
430
  lines.push(`export default app;`);
389
431
  return lines.join("\n");
390
432
  }
391
- const MAX_BODY_SIZE = 1024 * 1024;
392
- function readBody(req) {
393
- return new Promise((resolve, reject) => {
394
- let data = "";
395
- let size = 0;
396
- req.on("data", (chunk) => {
397
- size += chunk.length;
398
- if (size > MAX_BODY_SIZE) {
399
- req.destroy?.();
400
- reject(new RpcError("Request body too large", 413));
401
- return;
402
- }
403
- data += chunk.toString();
404
- });
405
- req.on("end", () => resolve(data));
406
- req.on("error", reject);
433
+ /**
434
+ * Node.js IncomingMessage → Web Standard Request に変換する。
435
+ * Vite connect middleware から Hono に橋渡しするために使う。
436
+ */
437
+ function toWebRequest(nodeReq) {
438
+ const url = new URL(nodeReq.url, `http://${nodeReq.headers.host || "localhost"}`);
439
+ const headers = new Headers();
440
+ for (const [key, value] of Object.entries(nodeReq.headers)) {
441
+ if (value === void 0) continue;
442
+ if (Array.isArray(value)) for (const v of value) headers.append(key, v);
443
+ else headers.set(key, value);
444
+ }
445
+ const hasBody = nodeReq.method === "POST" || nodeReq.method === "PUT" || nodeReq.method === "PATCH";
446
+ return new Request(url, {
447
+ method: nodeReq.method,
448
+ headers,
449
+ ...hasBody ? {
450
+ body: Readable.toWeb(nodeReq),
451
+ duplex: "half"
452
+ } : {}
407
453
  });
408
454
  }
455
+ /**
456
+ * Web Standard Response → Node.js ServerResponse に書き戻す。
457
+ */
458
+ async function writeWebResponse(webRes, nodeRes) {
459
+ nodeRes.statusCode = webRes.status;
460
+ for (const [key, value] of webRes.headers) {
461
+ if (key.toLowerCase() === "set-cookie") continue;
462
+ nodeRes.setHeader(key, value);
463
+ }
464
+ const cookies = webRes.headers.getSetCookie?.();
465
+ if (cookies?.length) nodeRes.setHeader("set-cookie", cookies);
466
+ const body = await webRes.arrayBuffer();
467
+ nodeRes.end(Buffer.from(body));
468
+ }
409
469
  //#endregion
410
- export { orbitRpc };
470
+ export { contextStorage, getContext, orbitRpc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orbit-rpc",
3
- "version": "0.2.0",
3
+ "version": "1.1.0",
4
4
  "description": "RPC layer for Orbit — auto-converts server.ts functions to Hono routes",
5
5
  "keywords": [
6
6
  "fullstack",