orbit-rpc 1.0.0 → 1.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 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,45 @@ 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
+ * アプリ側で環境変数の型を登録するためのインターフェース。
18
+ *
19
+ * ```ts
20
+ * // src/lib/context.ts
21
+ * declare module "orbit-rpc" {
22
+ * interface Register {
23
+ * env: { DB: D1Database; ADMIN_PASSWORD: string };
24
+ * }
25
+ * }
26
+ * ```
27
+ */
28
+ interface Register {}
29
+ /** Register.env が登録済みなら使い、未登録なら Record<string, unknown> にフォールバック */
30
+ type RegisteredEnv = Register extends {
31
+ env: infer T;
32
+ } ? T : Record<string, unknown>;
33
+ /**
34
+ * RPC ハンドラ内で Hono Context を取得する。
35
+ *
36
+ * server.ts の関数内から呼ぶと、現在のリクエストに紐づく Hono Context が返る。
37
+ * Cloudflare Workers のバインディング(D1, KV 等)や Cookie へのアクセスに使う。
38
+ *
39
+ * ```ts
40
+ * // src/lib/context.ts で型を1回宣言すれば:
41
+ * declare module "orbit-rpc" {
42
+ * interface Register { env: { DB: D1Database } }
43
+ * }
44
+ *
45
+ * // server.ts では型パラメータ不要で使える:
46
+ * import { getContext } from "orbit-rpc";
47
+ * const c = getContext();
48
+ * c.env.DB // ← 型補完が効く
49
+ * ```
50
+ */
51
+ declare function getContext<E extends Record<string, unknown> = RegisteredEnv>(): Context<{
52
+ Bindings: E;
53
+ }>;
54
+ //#endregion
55
+ export { type OrbitRpcConfig, type Register, contextStorage, getContext, orbitRpc };
package/dist/index.mjs CHANGED
@@ -1,5 +1,34 @@
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
+ * ```ts
15
+ * // src/lib/context.ts で型を1回宣言すれば:
16
+ * declare module "orbit-rpc" {
17
+ * interface Register { env: { DB: D1Database } }
18
+ * }
19
+ *
20
+ * // server.ts では型パラメータ不要で使える:
21
+ * import { getContext } from "orbit-rpc";
22
+ * const c = getContext();
23
+ * c.env.DB // ← 型補完が効く
24
+ * ```
25
+ */
26
+ function getContext() {
27
+ const ctx = contextStorage.getStore();
28
+ 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.");
29
+ return ctx;
30
+ }
31
+ //#endregion
3
32
  //#region src/scanner.ts
4
33
  /**
5
34
  * routes ディレクトリから server.ts ファイルをスキャンし、
@@ -159,7 +188,7 @@ function dirToRoutePrefix(relativePath) {
159
188
  *
160
189
  * 2つの仕事をする:
161
190
  * 1. クライアント側: server.ts の import を HTTP fetch スタブに差し替え
162
- * 2. dev サーバー: /rpc/* リクエストを受けて server.ts の関数を実行
191
+ * 2. dev サーバー / 本番ビルド: Hono アプリで RPC リクエストを処理
163
192
  *
164
193
  * schema.ts に Zod スキーマがあれば、自動でバリデーションを適用する。
165
194
  */
@@ -215,17 +244,18 @@ function orbitRpc(config = {}) {
215
244
  server.watcher.on("add", onFileChange);
216
245
  server.watcher.on("change", onFileChange);
217
246
  server.watcher.on("unlink", onFileChange);
247
+ const devApp = createDevRpcApp(server, rpcBase, () => serverModules);
218
248
  server.middlewares.use(async (req, res, next) => {
219
249
  if (!req.url?.startsWith(rpcBase)) return next();
220
250
  try {
221
- const result = await handleRpcRequest(server, req, rpcBase, serverModules);
222
- res.setHeader("Content-Type", "application/json");
223
- res.end(JSON.stringify(result ?? null));
251
+ const webReq = toWebRequest(req);
252
+ await writeWebResponse(await devApp.fetch(webReq), res);
224
253
  } 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 }));
254
+ if (!res.headersSent) {
255
+ res.statusCode = 500;
256
+ res.setHeader("Content-Type", "application/json");
257
+ res.end(JSON.stringify({ error: "Internal Server Error" }));
258
+ }
229
259
  }
230
260
  });
231
261
  }
@@ -267,51 +297,50 @@ function generateClientStub(mod, rpcBase) {
267
297
  }
268
298
  return lines.join("\n");
269
299
  }
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));
300
+ function createDevRpcApp(server, rpcBase, getModules) {
301
+ const app = new Hono();
302
+ app.post(`${rpcBase}/*`, async (c) => {
303
+ const rpcPath = c.req.path.slice(rpcBase.length);
304
+ const lastSlash = rpcPath.lastIndexOf("/");
305
+ if (lastSlash === -1) return c.json({ error: "Invalid RPC path" }, 400);
306
+ const routePrefix = rpcPath.slice(0, lastSlash) || "";
307
+ const functionName = decodeURIComponent(rpcPath.slice(lastSlash + 1));
308
+ const mod = getModules().find((m) => m.routePrefix === routePrefix);
309
+ if (!mod) return c.json({ error: `Module not found: ${routePrefix}` }, 404);
310
+ const fnDef = mod.functions.find((f) => f.name === functionName);
311
+ if (!fnDef) return c.json({ error: `Function not found: ${functionName}` }, 404);
312
+ const fn = (await server.ssrLoadModule(mod.filePath))[functionName];
313
+ if (typeof fn !== "function") return c.json({ error: `${functionName} is not a function` }, 500);
314
+ if (Number(c.req.header("content-length") ?? 0) > 1048576) return c.json({ error: "Payload too large" }, 413);
315
+ const body = await c.req.text();
316
+ if (body.length > 1048576) return c.json({ error: "Payload too large" }, 413);
317
+ let args;
318
+ try {
319
+ args = body ? JSON.parse(body) : [];
320
+ } catch {
321
+ return c.json({ error: "Invalid JSON in request body" }, 400);
322
+ }
323
+ if (!Array.isArray(args)) return c.json({ error: "Request body must be a JSON array" }, 400);
324
+ let validatedArgs;
325
+ try {
326
+ validatedArgs = await validateArgs(args, fnDef, mod, (p) => server.ssrLoadModule(p));
327
+ } catch (err) {
328
+ if (err instanceof RpcValidationError) return c.json({ error: err.message }, 400);
329
+ throw err;
330
+ }
331
+ const result = await contextStorage.run(c, () => fn(...validatedArgs));
332
+ return c.json(result ?? null);
333
+ });
334
+ app.all(`${rpcBase}/*`, (c) => c.json({ error: "Method not allowed" }, 405));
335
+ return app;
306
336
  }
307
337
  /**
308
338
  * 各引数を対応する Zod スキーマでバリデーションする。
309
339
  * スキーマが紐付いていない引数はそのまま通す。
310
- * 引数が不足していてもスキーマがあればバリデーションを実行する(Zod が required エラーを出す)。
311
340
  */
312
- async function validateArgs(args, fnDef, mod, server) {
341
+ async function validateArgs(args, fnDef, mod, loadModule) {
313
342
  if (!fnDef.params.some((p) => p.schemaName) || !mod.schemaFilePath) return args;
314
- const schemaModule = await server.ssrLoadModule(mod.schemaFilePath);
343
+ const schemaModule = await loadModule(mod.schemaFilePath);
315
344
  const validated = [...args];
316
345
  for (let i = 0; i < fnDef.params.length; i++) {
317
346
  const param = fnDef.params[i];
@@ -321,13 +350,18 @@ async function validateArgs(args, fnDef, mod, server) {
321
350
  const result = schema.safeParse(args[i]);
322
351
  if (!result.success) {
323
352
  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);
353
+ throw new RpcValidationError(`Validation error on "${param.name}": ${issues}`);
325
354
  }
326
355
  validated[i] = result.data;
327
356
  }
328
357
  }
329
358
  return validated;
330
359
  }
360
+ var RpcValidationError = class extends Error {
361
+ constructor(message) {
362
+ super(message);
363
+ }
364
+ };
331
365
  /**
332
366
  * 本番用 Hono アプリのコードを生成する。
333
367
  *
@@ -339,6 +373,7 @@ async function validateArgs(args, fnDef, mod, server) {
339
373
  function generateHonoApp(modules, root, rpcBase) {
340
374
  const lines = [];
341
375
  lines.push(`import { Hono } from "hono";`);
376
+ lines.push(`import { contextStorage } from "orbit-rpc";`);
342
377
  lines.push(``);
343
378
  const validModules = [];
344
379
  for (const [i, mod] of modules.entries()) {
@@ -361,6 +396,8 @@ function generateHonoApp(modules, root, rpcBase) {
361
396
  const endpoint = `${rpcBase}${mod.routePrefix}/${fn.name}`;
362
397
  lines.push(`app.post("${endpoint}", async (c) => {`);
363
398
  lines.push(` try {`);
399
+ lines.push(` const cl = Number(c.req.header("content-length") ?? 0);`);
400
+ lines.push(` if (cl > 1048576) return c.json({ error: "Payload too large" }, 413);`);
364
401
  lines.push(` const body = await c.req.text();`);
365
402
  lines.push(` if (body.length > 1048576) return c.json({ error: "Payload too large" }, 413);`);
366
403
  lines.push(` let args;`);
@@ -376,7 +413,7 @@ function generateHonoApp(modules, root, rpcBase) {
376
413
  lines.push(` }`);
377
414
  }
378
415
  }
379
- lines.push(` const result = await mod${i}.${fn.name}(...args);`);
416
+ lines.push(` const result = await contextStorage.run(c, () => mod${i}.${fn.name}(...args));`);
380
417
  lines.push(` return c.json(result ?? null);`);
381
418
  lines.push(` } catch (err) {`);
382
419
  lines.push(` console.error(err);`);
@@ -388,23 +425,41 @@ function generateHonoApp(modules, root, rpcBase) {
388
425
  lines.push(`export default app;`);
389
426
  return lines.join("\n");
390
427
  }
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);
428
+ /**
429
+ * Node.js IncomingMessage → Web Standard Request に変換する。
430
+ * Vite connect middleware から Hono に橋渡しするために使う。
431
+ */
432
+ function toWebRequest(nodeReq) {
433
+ const url = new URL(nodeReq.url, `http://${nodeReq.headers.host || "localhost"}`);
434
+ const headers = new Headers();
435
+ for (const [key, value] of Object.entries(nodeReq.headers)) {
436
+ if (value === void 0) continue;
437
+ if (Array.isArray(value)) for (const v of value) headers.append(key, v);
438
+ else headers.set(key, value);
439
+ }
440
+ const hasBody = nodeReq.method === "POST" || nodeReq.method === "PUT" || nodeReq.method === "PATCH";
441
+ return new Request(url, {
442
+ method: nodeReq.method,
443
+ headers,
444
+ ...hasBody ? {
445
+ body: Readable.toWeb(nodeReq),
446
+ duplex: "half"
447
+ } : {}
407
448
  });
408
449
  }
450
+ /**
451
+ * Web Standard Response → Node.js ServerResponse に書き戻す。
452
+ */
453
+ async function writeWebResponse(webRes, nodeRes) {
454
+ nodeRes.statusCode = webRes.status;
455
+ for (const [key, value] of webRes.headers) {
456
+ if (key.toLowerCase() === "set-cookie") continue;
457
+ nodeRes.setHeader(key, value);
458
+ }
459
+ const cookies = webRes.headers.getSetCookie?.();
460
+ if (cookies?.length) nodeRes.setHeader("set-cookie", cookies);
461
+ const body = await webRes.arrayBuffer();
462
+ nodeRes.end(Buffer.from(body));
463
+ }
409
464
  //#endregion
410
- export { orbitRpc };
465
+ export { contextStorage, getContext, orbitRpc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orbit-rpc",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "RPC layer for Orbit — auto-converts server.ts functions to Hono routes",
5
5
  "keywords": [
6
6
  "fullstack",