orbit-rpc 0.0.1 → 0.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 +19 -0
- package/dist/index.mjs +200 -0
- package/package.json +45 -5
- package/index.js +0 -1
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Plugin } from "vite";
|
|
2
|
+
|
|
3
|
+
//#region src/plugin.d.ts
|
|
4
|
+
interface OrbitRpcConfig {
|
|
5
|
+
/** routes ディレクトリのパス(デフォルト: "src/routes") */
|
|
6
|
+
routesDir?: string;
|
|
7
|
+
/** RPC エンドポイントのプレフィックス(デフォルト: "/rpc") */
|
|
8
|
+
rpcBase?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Orbit RPC Vite プラグイン。
|
|
12
|
+
*
|
|
13
|
+
* 2つの仕事をする:
|
|
14
|
+
* 1. クライアント側: server.ts の import を HTTP fetch スタブに差し替え
|
|
15
|
+
* 2. dev サーバー: /rpc/* リクエストを受けて server.ts の関数を実行
|
|
16
|
+
*/
|
|
17
|
+
declare function orbitRpc(config?: OrbitRpcConfig): Plugin[];
|
|
18
|
+
//#endregion
|
|
19
|
+
export { type OrbitRpcConfig, orbitRpc };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
//#region src/scanner.ts
|
|
4
|
+
/**
|
|
5
|
+
* routes ディレクトリから server.ts ファイルをスキャンし、
|
|
6
|
+
* エクスポートされた関数名を抽出する。
|
|
7
|
+
*/
|
|
8
|
+
async function scanServerModules(root, routesDir) {
|
|
9
|
+
const absoluteRoutesDir = path.resolve(root, routesDir);
|
|
10
|
+
if (!fs.existsSync(absoluteRoutesDir)) return [];
|
|
11
|
+
const modules = [];
|
|
12
|
+
await walk(absoluteRoutesDir, absoluteRoutesDir, modules);
|
|
13
|
+
return modules;
|
|
14
|
+
}
|
|
15
|
+
async function walk(dir, routesRoot, modules) {
|
|
16
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
17
|
+
const serverFile = entries.find((e) => e.isFile() && /^server\.ts$/.test(e.name));
|
|
18
|
+
if (serverFile) {
|
|
19
|
+
const filePath = path.join(dir, serverFile.name);
|
|
20
|
+
const routePrefix = dirToRoutePrefix(path.relative(routesRoot, dir));
|
|
21
|
+
const functions = extractExportedFunctions(filePath);
|
|
22
|
+
if (functions.length > 0) modules.push({
|
|
23
|
+
filePath,
|
|
24
|
+
routePrefix,
|
|
25
|
+
functions
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
for (const entry of entries) if (entry.isDirectory() && !entry.name.startsWith("_")) await walk(path.join(dir, entry.name), routesRoot, modules);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* server.ts から export された関数名を抽出する。
|
|
32
|
+
* 簡易パース(正規表現ベース)。
|
|
33
|
+
*/
|
|
34
|
+
function extractExportedFunctions(filePath) {
|
|
35
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
36
|
+
const functions = [];
|
|
37
|
+
for (const match of content.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g)) functions.push({ name: match[1] });
|
|
38
|
+
for (const match of content.matchAll(/export\s+const\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)\s*=>|function\b)/g)) if (!functions.some((f) => f.name === match[1])) functions.push({ name: match[1] });
|
|
39
|
+
return functions;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* ディレクトリの相対パスをルートプレフィックスに変換する。
|
|
43
|
+
* "" → ""
|
|
44
|
+
* "tasks" → "/tasks"
|
|
45
|
+
* "users/[id]" ��� "/users/:id"
|
|
46
|
+
*/
|
|
47
|
+
function dirToRoutePrefix(relativePath) {
|
|
48
|
+
if (relativePath === "") return "";
|
|
49
|
+
const segments = relativePath.split(path.sep).filter((seg) => !/^\(.+\)$/.test(seg)).map((seg) => {
|
|
50
|
+
const match = seg.match(/^\[(.+)]$/);
|
|
51
|
+
return match ? `:${match[1]}` : seg;
|
|
52
|
+
});
|
|
53
|
+
return segments.length === 0 ? "" : `/${segments.join("/")}`;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/plugin.ts
|
|
57
|
+
/**
|
|
58
|
+
* Orbit RPC Vite プラグイン。
|
|
59
|
+
*
|
|
60
|
+
* 2つの仕事をする:
|
|
61
|
+
* 1. クライアント側: server.ts の import を HTTP fetch スタブに差し替え
|
|
62
|
+
* 2. dev サーバー: /rpc/* リクエストを受けて server.ts の関数を実行
|
|
63
|
+
*/
|
|
64
|
+
function orbitRpc(config = {}) {
|
|
65
|
+
const routesDir = config.routesDir ?? "src/routes";
|
|
66
|
+
const rpcBase = config.rpcBase ?? "/rpc";
|
|
67
|
+
let root;
|
|
68
|
+
let serverModules = [];
|
|
69
|
+
return [{
|
|
70
|
+
name: "orbit-rpc:transform",
|
|
71
|
+
configResolved(resolvedConfig) {
|
|
72
|
+
root = resolvedConfig.root;
|
|
73
|
+
},
|
|
74
|
+
async buildStart() {
|
|
75
|
+
serverModules = await scanServerModules(root, routesDir);
|
|
76
|
+
},
|
|
77
|
+
async transform(code, id) {
|
|
78
|
+
if (!id.endsWith("/server.ts")) return null;
|
|
79
|
+
const routesPath = path.resolve(root, routesDir);
|
|
80
|
+
if (!id.startsWith(routesPath)) return null;
|
|
81
|
+
if (this.environment?.name === "ssr") return null;
|
|
82
|
+
const mod = serverModules.find((m) => m.filePath === id);
|
|
83
|
+
if (!mod) {
|
|
84
|
+
serverModules = await scanServerModules(root, routesDir);
|
|
85
|
+
const freshMod = serverModules.find((m) => m.filePath === id);
|
|
86
|
+
if (!freshMod) return null;
|
|
87
|
+
return generateClientStub(freshMod, rpcBase);
|
|
88
|
+
}
|
|
89
|
+
return generateClientStub(mod, rpcBase);
|
|
90
|
+
}
|
|
91
|
+
}, {
|
|
92
|
+
name: "orbit-rpc:dev-server",
|
|
93
|
+
configureServer(server) {
|
|
94
|
+
const routesPath = path.resolve(root, routesDir);
|
|
95
|
+
const onFileChange = async (file) => {
|
|
96
|
+
if (!file.startsWith(routesPath)) return;
|
|
97
|
+
if (!file.endsWith("/server.ts")) return;
|
|
98
|
+
serverModules = await scanServerModules(root, routesDir);
|
|
99
|
+
};
|
|
100
|
+
server.watcher.on("add", onFileChange);
|
|
101
|
+
server.watcher.on("unlink", onFileChange);
|
|
102
|
+
server.middlewares.use(async (req, res, next) => {
|
|
103
|
+
if (!req.url?.startsWith(rpcBase)) return next();
|
|
104
|
+
try {
|
|
105
|
+
const result = await handleRpcRequest(server, req, rpcBase, serverModules);
|
|
106
|
+
res.setHeader("Content-Type", "application/json");
|
|
107
|
+
res.end(JSON.stringify(result ?? null));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const message = err instanceof Error ? err.message : "Internal Server Error";
|
|
110
|
+
res.statusCode = err instanceof RpcError ? err.status : 500;
|
|
111
|
+
res.setHeader("Content-Type", "application/json");
|
|
112
|
+
res.end(JSON.stringify({ error: message }));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* server.ts の中身を RPC スタブに差し替える。
|
|
120
|
+
*
|
|
121
|
+
* 例: routePrefix="/tasks", rpcBase="/rpc"
|
|
122
|
+
* export async function getTasks(signal) { ... }
|
|
123
|
+
* → export async function getTasks(signal) {
|
|
124
|
+
* const res = await fetch("/rpc/tasks/getTasks", { method: "POST", signal });
|
|
125
|
+
* ...
|
|
126
|
+
* }
|
|
127
|
+
*/
|
|
128
|
+
function generateClientStub(mod, rpcBase) {
|
|
129
|
+
const lines = [];
|
|
130
|
+
for (const fn of mod.functions) {
|
|
131
|
+
const endpoint = `${rpcBase}${mod.routePrefix}/${fn.name}`;
|
|
132
|
+
lines.push(`export async function ${fn.name}(...args) {`);
|
|
133
|
+
lines.push(` const signal = args[args.length - 1] instanceof AbortSignal ? args.pop() : undefined;`);
|
|
134
|
+
lines.push(` const hasArgs = args.length > 0;`);
|
|
135
|
+
lines.push(` const res = await fetch("${endpoint}", {`);
|
|
136
|
+
lines.push(` method: "POST",`);
|
|
137
|
+
lines.push(` signal,`);
|
|
138
|
+
lines.push(` ...(hasArgs ? {`);
|
|
139
|
+
lines.push(` headers: { "Content-Type": "application/json" },`);
|
|
140
|
+
lines.push(` body: JSON.stringify(args),`);
|
|
141
|
+
lines.push(` } : {}),`);
|
|
142
|
+
lines.push(` });`);
|
|
143
|
+
lines.push(` if (!res.ok) {`);
|
|
144
|
+
lines.push(` const body = await res.json().catch(() => ({}));`);
|
|
145
|
+
lines.push(` throw new Error(body.error || \`RPC error: \${res.status}\`);`);
|
|
146
|
+
lines.push(` }`);
|
|
147
|
+
lines.push(` const text = await res.text();`);
|
|
148
|
+
lines.push(` return text ? JSON.parse(text) : undefined;`);
|
|
149
|
+
lines.push(`}`);
|
|
150
|
+
lines.push(``);
|
|
151
|
+
}
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
var RpcError = class extends Error {
|
|
155
|
+
constructor(message, status) {
|
|
156
|
+
super(message);
|
|
157
|
+
this.status = status;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* dev サーバーで RPC リクエストを処理する。
|
|
162
|
+
*
|
|
163
|
+
* URL: POST /rpc/{routePrefix}/{functionName}
|
|
164
|
+
* Body: JSON(関数の引数)
|
|
165
|
+
*/
|
|
166
|
+
async function handleRpcRequest(server, req, rpcBase, modules) {
|
|
167
|
+
if (req.method !== "POST") throw new RpcError("Method not allowed", 405);
|
|
168
|
+
const rpcPath = req.url.slice(rpcBase.length);
|
|
169
|
+
const lastSlash = rpcPath.lastIndexOf("/");
|
|
170
|
+
if (lastSlash === -1) throw new RpcError("Invalid RPC path", 400);
|
|
171
|
+
const routePrefix = rpcPath.slice(0, lastSlash) || "";
|
|
172
|
+
const functionName = decodeURIComponent(rpcPath.slice(lastSlash + 1));
|
|
173
|
+
const mod = modules.find((m) => m.routePrefix === routePrefix);
|
|
174
|
+
if (!mod) throw new RpcError(`Module not found: ${routePrefix}`, 404);
|
|
175
|
+
if (!mod.functions.find((f) => f.name === functionName)) throw new RpcError(`Function not found: ${functionName}`, 404);
|
|
176
|
+
const fn = (await server.ssrLoadModule(mod.filePath))[functionName];
|
|
177
|
+
if (typeof fn !== "function") throw new RpcError(`${functionName} is not a function`, 500);
|
|
178
|
+
const body = await readBody(req);
|
|
179
|
+
return fn(...body ? JSON.parse(body) : []);
|
|
180
|
+
}
|
|
181
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
182
|
+
function readBody(req) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
let data = "";
|
|
185
|
+
let size = 0;
|
|
186
|
+
req.on("data", (chunk) => {
|
|
187
|
+
size += chunk.length;
|
|
188
|
+
if (size > MAX_BODY_SIZE) {
|
|
189
|
+
req.destroy();
|
|
190
|
+
reject(new RpcError("Request body too large", 413));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
data += chunk.toString();
|
|
194
|
+
});
|
|
195
|
+
req.on("end", () => resolve(data));
|
|
196
|
+
req.on("error", reject);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
export { orbitRpc };
|
package/package.json
CHANGED
|
@@ -1,11 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orbit-rpc",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "RPC layer for Orbit — auto-converts server.ts functions to Hono routes",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"rpc",
|
|
7
|
+
"hono",
|
|
8
|
+
"vite",
|
|
9
|
+
"react",
|
|
10
|
+
"fullstack"
|
|
11
|
+
],
|
|
5
12
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
13
|
+
"author": "ashunar0",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/ashunar0/orbit-router",
|
|
17
|
+
"directory": "packages/orbit-rpc"
|
|
18
|
+
},
|
|
8
19
|
"files": [
|
|
9
|
-
"
|
|
10
|
-
]
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"import": "./dist/index.mjs"
|
|
28
|
+
},
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"hono": "^4.7.11"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^25.5.0",
|
|
36
|
+
"bumpp": "^11.0.1",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.12",
|
|
39
|
+
"vite-plus": "0.1.12"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"vite": ">=6.0.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "vp pack",
|
|
46
|
+
"dev": "vp pack --watch",
|
|
47
|
+
"test": "vp test",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"release": "bumpp"
|
|
50
|
+
}
|
|
11
51
|
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|