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 +44 -1
- package/dist/index.mjs +121 -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,45 @@ 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
|
+
* アプリ側で環境変数の型を登録するためのインターフェース。
|
|
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
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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 };
|