orbit-rpc 1.0.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/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()) {
@@ -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": "1.0.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",