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 +32 -1
- package/dist/index.mjs +126 -66
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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 };
|