@statewalker/webrun-rpc-http 0.1.1
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/LICENSE +21 -0
- package/README.md +287 -0
- package/dist/get-instance-methods.d.ts +7 -0
- package/dist/get-instance-methods.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +238 -0
- package/dist/new-rpc-client.d.ts +25 -0
- package/dist/new-rpc-client.d.ts.map +1 -0
- package/dist/new-rpc-server.d.ts +24 -0
- package/dist/new-rpc-server.d.ts.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +49 -0
- package/src/get-instance-methods.ts +26 -0
- package/src/index.ts +4 -0
- package/src/new-rpc-client.ts +93 -0
- package/src/new-rpc-server.ts +188 -0
- package/src/types.ts +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026 statewalker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# @statewalker/webrun-rpc-http
|
|
2
|
+
|
|
3
|
+
HTTP-based service RPC. Expose plain object methods as a standard
|
|
4
|
+
`(Request) ⇒ Response` handler; call them from anywhere with `fetch`.
|
|
5
|
+
|
|
6
|
+
## Why it exists
|
|
7
|
+
|
|
8
|
+
The HTTP primitives in
|
|
9
|
+
[`@statewalker/webrun-http-streams`](../webrun-http-streams) let you write a
|
|
10
|
+
handler that answers `fetch()` — over the wire, in the same tab, inside a
|
|
11
|
+
ServiceWorker, across a MessagePort bridge. What they don't give you is a
|
|
12
|
+
layer above that: a way to take a plain service object and turn its methods
|
|
13
|
+
into addressable endpoints.
|
|
14
|
+
|
|
15
|
+
`webrun-rpc-http` is that layer. Deliberately small — two factory
|
|
16
|
+
functions plus a handful of types:
|
|
17
|
+
|
|
18
|
+
- `newRpcServer(services, {path?}) ⇒ (Request) ⇒ Response` — one handler
|
|
19
|
+
that routes `GET /`, `GET /{service}`, and
|
|
20
|
+
`GET|POST /{service}/{method}` into object method calls.
|
|
21
|
+
- `newRpcClient({baseUrl, fetch?})` — lazily fetches the service
|
|
22
|
+
descriptor and returns typed proxies whose method calls round-trip
|
|
23
|
+
through `fetch`.
|
|
24
|
+
|
|
25
|
+
Because the server is *just* a `(Request) ⇒ Response` handler and the
|
|
26
|
+
client is *just* `fetch`, the exact same RPC code runs over whatever
|
|
27
|
+
transport you wire it to:
|
|
28
|
+
|
|
29
|
+
| Transport | How |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| Real HTTP over the network | Default: `fetch = globalThis.fetch`. |
|
|
32
|
+
| An in-browser ServiceWorker | `@statewalker/webrun-http-browser` — the SW intercepts the standard `fetch` call with no special wiring. |
|
|
33
|
+
| In-process tests | Pass `fetch: (request) => handler(request)` — no network at all. |
|
|
34
|
+
| A MessagePort channel | `@statewalker/webrun-streams-port` for the `Duplex`, then `fetchOverDuplex` / `serveFetchOverDuplex` from `@statewalker/webrun-http-streams`. |
|
|
35
|
+
| A WebSocket | Same, with `@statewalker/webrun-streams-ws` supplying the `Duplex`. |
|
|
36
|
+
| Deno / Cloudflare Workers / Node's built-in HTTP | The handler is `(Request) ⇒ Response` — drop in as-is. |
|
|
37
|
+
|
|
38
|
+
## How to use
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm install @statewalker/webrun-rpc-http
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Exports
|
|
45
|
+
|
|
46
|
+
| Export | Purpose |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `newRpcServer(services, opts?)` | Build a `(Request) ⇒ Response` handler from a map of service objects. Accepts `{ path }` to mount under a URL prefix. |
|
|
49
|
+
| `newRpcClient({baseUrl, fetch?})` | Build a lazy RPC client. Returns `{ loadService<T>(name) }`; the descriptor at `baseUrl/` is fetched once on first call and cached. |
|
|
50
|
+
| `RpcMethod` | `(params: Json, body?: Blob) ⇒ Promise<Blob \| Json>` — the shape every exposed method must satisfy. |
|
|
51
|
+
| `RpcClient` | Shape of the object returned from `newRpcClient`: `{ loadService<T>(name) }`. |
|
|
52
|
+
| `Json` / `JsonObject` | Recursive JSON types — everything that survives a `JSON` round-trip. |
|
|
53
|
+
| `getInstanceMethods(instance)` | Reflect callable properties of `instance` into a `Record<string, Function>`, walking the prototype chain up to (but not including) `Object.prototype`. Used internally by `newRpcServer`; exposed for callers that need it. |
|
|
54
|
+
|
|
55
|
+
## Examples
|
|
56
|
+
|
|
57
|
+
### Expose a service
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { newRpcServer } from "@statewalker/webrun-rpc-http";
|
|
61
|
+
|
|
62
|
+
class MathService {
|
|
63
|
+
async add(params: { a: number; b: number }) {
|
|
64
|
+
return params.a + params.b;
|
|
65
|
+
}
|
|
66
|
+
async bytes(params: { count: number }) {
|
|
67
|
+
return new Blob([new Uint8Array(params.count).fill(0xff)]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handler = newRpcServer({ math: new MathService() });
|
|
72
|
+
|
|
73
|
+
// Plug into anything that speaks Request ⇒ Response:
|
|
74
|
+
export default { fetch: handler }; // Deno / Bun / Cloudflare Workers
|
|
75
|
+
// or: Bun.serve({ fetch: handler, port: 8080 });
|
|
76
|
+
// or: http.createServer(/* adapt */) // Node's built-in http
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Out of the box the handler answers:
|
|
80
|
+
|
|
81
|
+
| Request | Response |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `GET /` | `{ "math": ["add", "bytes"] }` — service descriptor. |
|
|
84
|
+
| `GET /math` | `["add", "bytes"]` — method list. |
|
|
85
|
+
| `POST /math/add` — multipart with `params` JSON | `{ "type": "json", "result": 5 }` |
|
|
86
|
+
| `GET /math/add?a=2&b=3` | `{ "type": "json", "result": "23" }` — URL params are parsed into `params`, but as **strings**, so `add` concatenates. POST JSON when the argument type matters. |
|
|
87
|
+
| `POST /math/bytes` — Blob result | `application/octet-stream` body. |
|
|
88
|
+
|
|
89
|
+
### Call a service
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { newRpcClient } from "@statewalker/webrun-rpc-http";
|
|
93
|
+
|
|
94
|
+
const client = newRpcClient({ baseUrl: "https://api.example.com/rpc" });
|
|
95
|
+
const math = await client.loadService<MathService>("math");
|
|
96
|
+
|
|
97
|
+
await math.add({ a: 2, b: 3 }); // 5
|
|
98
|
+
const blob = (await math.bytes({ count: 16 })) as Blob;
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The descriptor at `baseUrl/` is fetched once, on the first `loadService`
|
|
102
|
+
call, and cached for every subsequent call on the same client instance.
|
|
103
|
+
Method proxies are lazy — they build one `POST` per invocation, no
|
|
104
|
+
persistent connection held open.
|
|
105
|
+
|
|
106
|
+
### Wire client directly to server (in-process)
|
|
107
|
+
|
|
108
|
+
Pass any `(Request) ⇒ Promise<Response>` as the `fetch` option. This is
|
|
109
|
+
how you run the client against an in-process server — unit tests,
|
|
110
|
+
webrun-http-browser SWs, a MessagePort bridge, a WebSocket bridge:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const handler = newRpcServer({ math: new MathService() });
|
|
114
|
+
const client = newRpcClient({
|
|
115
|
+
baseUrl: "http://in-process",
|
|
116
|
+
fetch: (request) => handler(request),
|
|
117
|
+
});
|
|
118
|
+
const math = await client.loadService<MathService>("math");
|
|
119
|
+
await math.add({ a: 1, b: 2 }); // 3 — no network.
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Mount in a browser ServiceWorker
|
|
123
|
+
|
|
124
|
+
Combine with `@statewalker/webrun-http-browser` to get an in-browser RPC
|
|
125
|
+
server that answers real `fetch()` calls:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { SwHttpAdapter } from "@statewalker/webrun-http-browser/sw";
|
|
129
|
+
import { newRpcServer } from "@statewalker/webrun-rpc-http";
|
|
130
|
+
|
|
131
|
+
const adapter = new SwHttpAdapter({
|
|
132
|
+
key: "api",
|
|
133
|
+
serviceWorkerUrl: new URL("./sw-worker.js", import.meta.url).toString(),
|
|
134
|
+
});
|
|
135
|
+
await adapter.start();
|
|
136
|
+
|
|
137
|
+
const { baseUrl } = await adapter.register(
|
|
138
|
+
"api/",
|
|
139
|
+
newRpcServer({ math: new MathService() }),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Now any page script can do:
|
|
143
|
+
const client = newRpcClient({ baseUrl });
|
|
144
|
+
const math = await client.loadService<MathService>("math");
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Mount under a path prefix
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
const handler = newRpcServer(services, { path: "/api/v1" });
|
|
151
|
+
|
|
152
|
+
// GET /api/v1/ → descriptor
|
|
153
|
+
// POST /api/v1/math/add → call
|
|
154
|
+
// GET /api/v1/math/add/foo/bar → call with params.$path === "foo/bar"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Requests outside the prefix get a 404 response with the same JSON-error
|
|
158
|
+
shape as any other error.
|
|
159
|
+
|
|
160
|
+
### Errors
|
|
161
|
+
|
|
162
|
+
Every error — method throws, unknown routes, descriptor failures —
|
|
163
|
+
returns a JSON object:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{ "type": "error", "message": "…", "stack": "…", "…any custom fields": "…" }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| Cause | HTTP status | Body |
|
|
170
|
+
| --- | --- | --- |
|
|
171
|
+
| Method body throws | 200 | `{ "type": "error", … }` — the call reached the method. |
|
|
172
|
+
| Method doesn't exist | 500 | Same shape — routing failure. |
|
|
173
|
+
| Unknown path | 404 | Same shape. |
|
|
174
|
+
| `response.ok === false` with non-JSON body | — | Client throws a generic `RPC call failed: status text`. |
|
|
175
|
+
|
|
176
|
+
On the client, every serialized error is rehydrated into an `Error`
|
|
177
|
+
instance via
|
|
178
|
+
[`@statewalker/webrun-streams`](../webrun-streams)'s `deserializeError`
|
|
179
|
+
— preserving `message`, `stack`, and any custom fields the server
|
|
180
|
+
attached to a thrown `Error` subclass.
|
|
181
|
+
|
|
182
|
+
## Internals
|
|
183
|
+
|
|
184
|
+
### Descriptor format
|
|
185
|
+
|
|
186
|
+
`getInstanceMethods` walks each service's prototype chain and picks up
|
|
187
|
+
every function-valued property except `constructor`, stopping before
|
|
188
|
+
`Object.prototype`. It returns a `Record<string, Function>`; the wire
|
|
189
|
+
descriptor is built from its keys. That means:
|
|
190
|
+
|
|
191
|
+
- Plain object literals `{ foo() {…} }` expose `foo`.
|
|
192
|
+
- Class instances expose methods defined on any ancestor class up to
|
|
193
|
+
`Object.prototype` (`toString`, `hasOwnProperty`, … are *not*
|
|
194
|
+
exposed).
|
|
195
|
+
- Inherited methods work: `class Child extends Parent` instance exposes
|
|
196
|
+
every method from both.
|
|
197
|
+
|
|
198
|
+
The descriptor shape is `Record<string, string[]>` — just names, no
|
|
199
|
+
signatures. Clients build proxy objects from the name list alone.
|
|
200
|
+
|
|
201
|
+
### Wire format — call encoding
|
|
202
|
+
|
|
203
|
+
| Method | Body |
|
|
204
|
+
| --- | --- |
|
|
205
|
+
| `POST /svc/method` | `multipart/form-data` with fields `params` (JSON-stringified) and optional `body` (`Blob`). |
|
|
206
|
+
| `GET /svc/method?k=v` | Query string parsed into JSON. Dot-separated keys nest: `?a.b=c&a.d=e` → `{ a: { b: "c", d: "e" } }`. |
|
|
207
|
+
|
|
208
|
+
All query-string values stay as **strings** (URLSearchParams' contract).
|
|
209
|
+
If your method expects numbers, POST JSON instead of GET'ing a querystring.
|
|
210
|
+
|
|
211
|
+
### Wire format — sub-path injection
|
|
212
|
+
|
|
213
|
+
The URL tail after `/svc/method/` is injected into `params.$path`:
|
|
214
|
+
|
|
215
|
+
- `GET /svc/method/foo/bar` → `params.$path === "foo/bar"`.
|
|
216
|
+
- Useful for REST-style handlers (`GET /files/read/some/deep/path`).
|
|
217
|
+
- `$path` is always present (empty string when there is no tail).
|
|
218
|
+
|
|
219
|
+
### Wire format — response encoding
|
|
220
|
+
|
|
221
|
+
| Method returns | Status | Body |
|
|
222
|
+
| --- | --- | --- |
|
|
223
|
+
| `Json` | 200 `application/json` | `{ "type": "json", "result": … }` |
|
|
224
|
+
| `Blob` | 200 `application/octet-stream` | Raw bytes. |
|
|
225
|
+
| thrown `Error` | 200 `application/json` | `{ "type": "error", message, stack, …}` |
|
|
226
|
+
| routing failure | 500 or 404 | Same `type: "error"` shape. |
|
|
227
|
+
|
|
228
|
+
### Design notes
|
|
229
|
+
|
|
230
|
+
- **Factory, not class.** Matches the rest of the workspace
|
|
231
|
+
(`newHttpClientStub`, `newHttpCodec`, `newBasicAuth`, …). No public
|
|
232
|
+
class surface to subclass; behaviour is configured via the options
|
|
233
|
+
object.
|
|
234
|
+
- **Cached descriptor, lazy load.** The first `loadService` call fires
|
|
235
|
+
the `GET baseUrl/` request; every subsequent call returns the same
|
|
236
|
+
proxy object. Restart the client (`newRpcClient(...)`) to refresh.
|
|
237
|
+
- **Errors go through `@statewalker/webrun-streams`.** One
|
|
238
|
+
(de)serialization format shared across the webrun stack; no duplicate
|
|
239
|
+
`errors.ts` file.
|
|
240
|
+
- **No client-side type generation.** The client returns
|
|
241
|
+
`Record<string, RpcMethod>` by default; pass a concrete interface with
|
|
242
|
+
`loadService<MyService>("name")` to recover typing. TypeScript
|
|
243
|
+
structural compatibility does the rest.
|
|
244
|
+
- **The `$path` trick is optional.** If your method doesn't read
|
|
245
|
+
`params.$path`, nothing changes. It's there for REST-style handlers
|
|
246
|
+
that care about the tail.
|
|
247
|
+
|
|
248
|
+
### Constraints
|
|
249
|
+
|
|
250
|
+
- **One prototype chain.** `getInstanceMethods` stops before
|
|
251
|
+
`Object.prototype`. Methods defined on `Object.prototype` are
|
|
252
|
+
deliberately excluded — you can't accidentally expose `toString`.
|
|
253
|
+
- **No streaming.** A method returns once, with one `Json` or `Blob`.
|
|
254
|
+
Use `@statewalker/webrun-http-streams` directly (`fetchOverDuplex` /
|
|
255
|
+
`serveFetchOverDuplex`) for streaming responses.
|
|
256
|
+
- **Path prefix without trailing slash.** `path: "/api"` is right;
|
|
257
|
+
`path: "/api/"` is normalized (trailing slash stripped).
|
|
258
|
+
- **URLSearchParams are strings.** `?a=1` → `params.a === "1"`. POST
|
|
259
|
+
JSON-encoded params whenever the argument type matters.
|
|
260
|
+
- **No content negotiation.** JSON in, JSON or binary out — nothing more.
|
|
261
|
+
- **`baseUrl` without trailing slash.** The client concatenates
|
|
262
|
+
`${baseUrl}/${service}/${method}`; trailing slash would double.
|
|
263
|
+
|
|
264
|
+
### Dependencies
|
|
265
|
+
|
|
266
|
+
Runtime:
|
|
267
|
+
|
|
268
|
+
- `@statewalker/webrun-streams` — error (de)serialization
|
|
269
|
+
(`serializeError` / `deserializeError`). Workspace-local.
|
|
270
|
+
|
|
271
|
+
Otherwise zero runtime deps: platform builtins only (`Request`,
|
|
272
|
+
`Response`, `FormData`, `Blob`, `URL`, `URLSearchParams`).
|
|
273
|
+
|
|
274
|
+
Dev: TypeScript, vitest, rolldown, rimraf, `@types/node` (catalog
|
|
275
|
+
versions from the monorepo root).
|
|
276
|
+
|
|
277
|
+
## Scripts
|
|
278
|
+
|
|
279
|
+
```sh
|
|
280
|
+
pnpm test # vitest run
|
|
281
|
+
pnpm run build # rolldown + tsc --emitDeclarationOnly
|
|
282
|
+
pnpm lint # biome check src tests
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT © statewalker
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect every callable property of `instance` — including inherited ones —
|
|
3
|
+
* up to (but not including) `Object.prototype`. Constructors and non-function
|
|
4
|
+
* properties are skipped.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getInstanceMethods<T>(instance: T): Record<string, (...args: unknown[]) => unknown>;
|
|
7
|
+
//# sourceMappingURL=get-instance-methods.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"get-instance-methods.d.ts","sourceRoot":"","sources":["../src/get-instance-methods.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAClC,QAAQ,EAAE,CAAC,GACV,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,CAkBjD"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
//#region src/get-instance-methods.ts
|
|
2
|
+
/**
|
|
3
|
+
* Collect every callable property of `instance` — including inherited ones —
|
|
4
|
+
* up to (but not including) `Object.prototype`. Constructors and non-function
|
|
5
|
+
* properties are skipped.
|
|
6
|
+
*/
|
|
7
|
+
function getInstanceMethods(instance) {
|
|
8
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9
|
+
const methods = {};
|
|
10
|
+
const target = instance;
|
|
11
|
+
for (let proto = target; proto && proto !== Object.prototype; proto = Reflect.getPrototypeOf(proto)) for (const key of Object.getOwnPropertyNames(proto)) {
|
|
12
|
+
if (seen.has(key) || key === "constructor") continue;
|
|
13
|
+
seen.add(key);
|
|
14
|
+
const value = Reflect.get(target, key);
|
|
15
|
+
if (typeof value !== "function") continue;
|
|
16
|
+
methods[key] = value;
|
|
17
|
+
}
|
|
18
|
+
return methods;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region ../webrun-streams/src/errors.ts
|
|
22
|
+
function serializeError(error) {
|
|
23
|
+
if (error instanceof Error) {
|
|
24
|
+
const out = {
|
|
25
|
+
message: error.message,
|
|
26
|
+
stack: error.stack
|
|
27
|
+
};
|
|
28
|
+
const bag = error;
|
|
29
|
+
for (const key of Object.keys(bag)) out[key] = bag[key];
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
if (typeof error === "object" && error !== null) {
|
|
33
|
+
const bag = error;
|
|
34
|
+
return {
|
|
35
|
+
message: String(bag.message ?? error),
|
|
36
|
+
...bag
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { message: String(error) };
|
|
40
|
+
}
|
|
41
|
+
function deserializeError(error) {
|
|
42
|
+
const payload = typeof error === "string" ? { message: error } : error;
|
|
43
|
+
return Object.assign(new Error(payload.message), payload);
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/new-rpc-client.ts
|
|
47
|
+
/**
|
|
48
|
+
* Build an RPC client that calls services exposed by {@link newRpcServer}.
|
|
49
|
+
*
|
|
50
|
+
* The descriptor at `GET {baseUrl}/` is fetched lazily on the first
|
|
51
|
+
* `loadService` call and cached for subsequent calls.
|
|
52
|
+
*/
|
|
53
|
+
function newRpcClient({ baseUrl, fetch = globalThis.fetch.bind(globalThis) }) {
|
|
54
|
+
let apiPromise = null;
|
|
55
|
+
const loadApi = async () => {
|
|
56
|
+
const response = await fetch(new Request(baseUrl));
|
|
57
|
+
if (!response.ok) throw new Error(`Failed to load services descriptor: ${response.status} ${response.statusText}`);
|
|
58
|
+
const descriptor = await response.json();
|
|
59
|
+
const services = {};
|
|
60
|
+
for (const [serviceName, methodNames] of Object.entries(descriptor)) {
|
|
61
|
+
const serviceApi = {};
|
|
62
|
+
for (const methodName of methodNames) serviceApi[methodName] = newRpcMethod(fetch, baseUrl, serviceName, methodName);
|
|
63
|
+
services[serviceName] = serviceApi;
|
|
64
|
+
}
|
|
65
|
+
return services;
|
|
66
|
+
};
|
|
67
|
+
return { async loadService(serviceName) {
|
|
68
|
+
if (!apiPromise) apiPromise = loadApi();
|
|
69
|
+
const service = (await apiPromise)[serviceName];
|
|
70
|
+
if (!service) throw new Error(`Service ${serviceName} not found`);
|
|
71
|
+
return service;
|
|
72
|
+
} };
|
|
73
|
+
}
|
|
74
|
+
function newRpcMethod(fetch, baseUrl, serviceName, methodName) {
|
|
75
|
+
return async (params = {}, body) => {
|
|
76
|
+
const url = `${baseUrl}/${serviceName}/${methodName}`;
|
|
77
|
+
const formData = new FormData();
|
|
78
|
+
formData.append("params", JSON.stringify(params));
|
|
79
|
+
if (body) formData.append("body", body);
|
|
80
|
+
const response = await fetch(new Request(url, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
body: formData
|
|
83
|
+
}));
|
|
84
|
+
if ((response.headers.get("Content-Type") || "").includes("application/json")) {
|
|
85
|
+
const json = await response.json();
|
|
86
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) throw new Error("RPC response is not a JSON object");
|
|
87
|
+
if (json.type === "error") throw deserializeError(json);
|
|
88
|
+
if (!response.ok) throw new Error(`RPC call failed: ${response.status} ${response.statusText}`);
|
|
89
|
+
return json.result;
|
|
90
|
+
}
|
|
91
|
+
if (!response.ok) throw new Error(`RPC call failed: ${response.status} ${response.statusText}`);
|
|
92
|
+
return response.blob();
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/new-rpc-server.ts
|
|
97
|
+
/**
|
|
98
|
+
* Build a webrun-http `(Request) ⇒ Response` handler that exposes every method
|
|
99
|
+
* of every service as an HTTP endpoint.
|
|
100
|
+
*
|
|
101
|
+
* Wire format:
|
|
102
|
+
* - `GET {path}/` → `{ [service]: [methodName, ...] }`
|
|
103
|
+
* - `GET {path}/{service}` → `[methodName, ...]`
|
|
104
|
+
* - `GET {path}/{service}/{method}?a.b=c` → calls the method with `{ a: { b: "c" } }`
|
|
105
|
+
* - `POST {path}/{service}/{method}` with multipart/form-data (`params` JSON
|
|
106
|
+
* + optional `body` Blob) → calls the method with the decoded args
|
|
107
|
+
*
|
|
108
|
+
* Results are returned as `{ type: "json", result }` JSON, or as an
|
|
109
|
+
* `application/octet-stream` blob when the method returns a `Blob`. Errors
|
|
110
|
+
* are serialized to `{ type: "error", message, stack, ... }` JSON.
|
|
111
|
+
*/
|
|
112
|
+
function newRpcServer(services, { path = "" } = {}) {
|
|
113
|
+
const prefix = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
114
|
+
const index = buildServiceIndex(services);
|
|
115
|
+
return async (request) => {
|
|
116
|
+
try {
|
|
117
|
+
const route = splitRequestPath(request, prefix);
|
|
118
|
+
if (!route) return errorResponse(/* @__PURE__ */ new Error("Not found"), 404);
|
|
119
|
+
const { serviceName, methodName, subPath } = route;
|
|
120
|
+
if (request.method === "GET" && !serviceName) return jsonResponse(describeServices(index));
|
|
121
|
+
if (request.method === "GET" && serviceName && !methodName) return jsonResponse(listMethods(index, serviceName));
|
|
122
|
+
if ((request.method === "GET" || request.method === "POST") && serviceName && methodName) {
|
|
123
|
+
const { params, body } = await parseCallArgs(request);
|
|
124
|
+
const result = await invoke(index, serviceName, methodName, subPath, params, body);
|
|
125
|
+
return result instanceof Blob ? blobResponse(result) : jsonResponse(result);
|
|
126
|
+
}
|
|
127
|
+
return errorResponse(/* @__PURE__ */ new Error("Not found"), 404);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return errorResponse(error, 500);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function buildServiceIndex(services) {
|
|
134
|
+
const index = {};
|
|
135
|
+
for (const [serviceName, service] of Object.entries(services)) {
|
|
136
|
+
const methods = getInstanceMethods(service);
|
|
137
|
+
const methodsIndex = {};
|
|
138
|
+
for (const [methodName, method] of Object.entries(methods)) methodsIndex[methodName] = async (params, body) => await method.call(service, params, body);
|
|
139
|
+
index[serviceName] = methodsIndex;
|
|
140
|
+
}
|
|
141
|
+
return index;
|
|
142
|
+
}
|
|
143
|
+
function splitRequestPath(request, prefix) {
|
|
144
|
+
const pathname = new URL(request.url).pathname;
|
|
145
|
+
if (prefix && !pathname.startsWith(prefix)) return null;
|
|
146
|
+
const [serviceName = "", methodName = "", ...tail] = pathname.substring(prefix.length + 1).split("/");
|
|
147
|
+
return {
|
|
148
|
+
serviceName: serviceName || void 0,
|
|
149
|
+
methodName: methodName || void 0,
|
|
150
|
+
subPath: tail.join("/")
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function parseCallArgs(request) {
|
|
154
|
+
if (request.method === "GET") return { params: parseQueryParams(new URL(request.url).searchParams) };
|
|
155
|
+
if ((request.headers.get("Content-Type") || "").startsWith("multipart/form-data")) {
|
|
156
|
+
const formData = await request.formData();
|
|
157
|
+
let params = {};
|
|
158
|
+
if (formData.has("params")) params = JSON.parse(formData.get("params"));
|
|
159
|
+
const body = formData.has("body") ? formData.get("body") : void 0;
|
|
160
|
+
return {
|
|
161
|
+
params,
|
|
162
|
+
body
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
params: {},
|
|
167
|
+
body: request.body ? await request.blob() : void 0
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Expand dot-separated query keys (`a.b=c`) into nested JSON objects.
|
|
172
|
+
* Repeated keys overwrite rather than merging — same as the original.
|
|
173
|
+
*/
|
|
174
|
+
function parseQueryParams(search) {
|
|
175
|
+
const root = {};
|
|
176
|
+
for (const [key, value] of search.entries()) {
|
|
177
|
+
const segments = key.split(".");
|
|
178
|
+
let cursor = root;
|
|
179
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
180
|
+
const seg = segments[i];
|
|
181
|
+
const existing = cursor[seg];
|
|
182
|
+
if (typeof existing !== "object" || existing === null || Array.isArray(existing)) cursor[seg] = {};
|
|
183
|
+
cursor = cursor[seg];
|
|
184
|
+
}
|
|
185
|
+
cursor[segments[segments.length - 1]] = value;
|
|
186
|
+
}
|
|
187
|
+
return root;
|
|
188
|
+
}
|
|
189
|
+
async function invoke(index, serviceName, methodName, subPath, params, body) {
|
|
190
|
+
if (params && typeof params === "object" && !Array.isArray(params)) params.$path = subPath;
|
|
191
|
+
const method = index[serviceName]?.[methodName];
|
|
192
|
+
if (!method) throw new Error(`Method ${methodName} not found in service ${serviceName}`);
|
|
193
|
+
try {
|
|
194
|
+
const result = await method(params, body);
|
|
195
|
+
return result instanceof Blob ? result : {
|
|
196
|
+
type: "json",
|
|
197
|
+
result
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return {
|
|
201
|
+
type: "error",
|
|
202
|
+
...serializeError(error)
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function describeServices(index) {
|
|
207
|
+
const out = {};
|
|
208
|
+
for (const [name, methods] of Object.entries(index)) out[name] = Object.keys(methods);
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
function listMethods(index, serviceName) {
|
|
212
|
+
const svc = index[serviceName];
|
|
213
|
+
return svc ? Object.keys(svc) : [];
|
|
214
|
+
}
|
|
215
|
+
function jsonResponse(body, status = 200) {
|
|
216
|
+
return new Response(JSON.stringify(body), {
|
|
217
|
+
status,
|
|
218
|
+
headers: { "Content-Type": "application/json" }
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function blobResponse(body) {
|
|
222
|
+
return new Response(body, {
|
|
223
|
+
status: 200,
|
|
224
|
+
headers: { "Content-Type": "application/octet-stream" }
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function errorResponse(error, status) {
|
|
228
|
+
const body = {
|
|
229
|
+
type: "error",
|
|
230
|
+
...serializeError(error)
|
|
231
|
+
};
|
|
232
|
+
return new Response(JSON.stringify(body), {
|
|
233
|
+
status,
|
|
234
|
+
headers: { "Content-Type": "application/json" }
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
export { getInstanceMethods, newRpcClient, newRpcServer };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RpcMethod } from "./types.js";
|
|
2
|
+
export interface NewRpcClientOptions {
|
|
3
|
+
/** Base URL of the RPC endpoint (no trailing slash). */
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
/**
|
|
6
|
+
* Optional fetch override. Defaults to `globalThis.fetch`. Pass a webrun-http
|
|
7
|
+
* handler here to run the client against an in-process server.
|
|
8
|
+
*/
|
|
9
|
+
fetch?: (request: Request) => Promise<Response>;
|
|
10
|
+
}
|
|
11
|
+
export interface RpcClient {
|
|
12
|
+
/**
|
|
13
|
+
* Load (and cache) the service descriptor and return a proxy object whose
|
|
14
|
+
* methods round-trip through the transport.
|
|
15
|
+
*/
|
|
16
|
+
loadService<T = Record<string, RpcMethod>>(serviceName: string): Promise<T>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build an RPC client that calls services exposed by {@link newRpcServer}.
|
|
20
|
+
*
|
|
21
|
+
* The descriptor at `GET {baseUrl}/` is fetched lazily on the first
|
|
22
|
+
* `loadService` call and cached for subsequent calls.
|
|
23
|
+
*/
|
|
24
|
+
export declare function newRpcClient({ baseUrl, fetch, }: NewRpcClientOptions): RpcClient;
|
|
25
|
+
//# sourceMappingURL=new-rpc-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"new-rpc-client.d.ts","sourceRoot":"","sources":["../src/new-rpc-client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAoB,SAAS,EAAE,MAAM,YAAY,CAAC;AAE9D,MAAM,WAAW,mBAAmB;IAClC,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,SAAS;IACxB;;;OAGG;IACH,WAAW,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAC7E;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,EAC3B,OAAO,EACP,KAAyC,GAC1C,EAAE,mBAAmB,GAAG,SAAS,CA+BjC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface NewRpcServerOptions {
|
|
2
|
+
/**
|
|
3
|
+
* URL prefix under which the RPC endpoints are mounted. A trailing slash is
|
|
4
|
+
* stripped. Empty (the default) mounts at the origin root.
|
|
5
|
+
*/
|
|
6
|
+
path?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Build a webrun-http `(Request) ⇒ Response` handler that exposes every method
|
|
10
|
+
* of every service as an HTTP endpoint.
|
|
11
|
+
*
|
|
12
|
+
* Wire format:
|
|
13
|
+
* - `GET {path}/` → `{ [service]: [methodName, ...] }`
|
|
14
|
+
* - `GET {path}/{service}` → `[methodName, ...]`
|
|
15
|
+
* - `GET {path}/{service}/{method}?a.b=c` → calls the method with `{ a: { b: "c" } }`
|
|
16
|
+
* - `POST {path}/{service}/{method}` with multipart/form-data (`params` JSON
|
|
17
|
+
* + optional `body` Blob) → calls the method with the decoded args
|
|
18
|
+
*
|
|
19
|
+
* Results are returned as `{ type: "json", result }` JSON, or as an
|
|
20
|
+
* `application/octet-stream` blob when the method returns a `Blob`. Errors
|
|
21
|
+
* are serialized to `{ type: "error", message, stack, ... }` JSON.
|
|
22
|
+
*/
|
|
23
|
+
export declare function newRpcServer(services: Record<string, object>, { path }?: NewRpcServerOptions): (request: Request) => Promise<Response>;
|
|
24
|
+
//# sourceMappingURL=new-rpc-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"new-rpc-server.d.ts","sourceRoot":"","sources":["../src/new-rpc-server.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,EAAE,IAAS,EAAE,GAAE,mBAAwB,GACtC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAyBzC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Any value that can survive a JSON round-trip. */
|
|
2
|
+
export type Json = string | number | boolean | null | Json[] | JsonObject;
|
|
3
|
+
/** A JSON object — the only valid shape for RPC call params. */
|
|
4
|
+
export interface JsonObject {
|
|
5
|
+
[key: string]: Json;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* An RPC method. Takes a JSON-shaped `params` argument and an optional binary
|
|
9
|
+
* `body`, returns a JSON value or a `Blob`.
|
|
10
|
+
*/
|
|
11
|
+
export type RpcMethod = (params: Json, body?: Blob) => Promise<Blob | Json>;
|
|
12
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,oDAAoD;AACpD,MAAM,MAAM,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,IAAI,EAAE,GAAG,UAAU,CAAC;AAE1E,gEAAgE;AAChE,MAAM,WAAW,UAAU;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,KAAK,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/webrun-rpc-http",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "HTTP-based service RPC: expose object methods as a (Request) => Response handler and call them with fetch",
|
|
7
|
+
"homepage": "https://github.com/statewalker/webrun-wire",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Mikhail Kotelnikov",
|
|
10
|
+
"email": "mikhail.kotelnikov@gmail.com"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git@github.com:statewalker/webrun-wire.git"
|
|
16
|
+
},
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"module": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@statewalker/webrun-streams": "0.1.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^26.2.0",
|
|
35
|
+
"rimraf": "^6.1.3",
|
|
36
|
+
"rolldown": "^1.2.4",
|
|
37
|
+
"typescript": "^7.0.2",
|
|
38
|
+
"vitest": "^4.1.10"
|
|
39
|
+
},
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"lint": "biome check src tests"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect every callable property of `instance` — including inherited ones —
|
|
3
|
+
* up to (but not including) `Object.prototype`. Constructors and non-function
|
|
4
|
+
* properties are skipped.
|
|
5
|
+
*/
|
|
6
|
+
export function getInstanceMethods<T>(
|
|
7
|
+
instance: T,
|
|
8
|
+
): Record<string, (...args: unknown[]) => unknown> {
|
|
9
|
+
const seen = new Set<string>();
|
|
10
|
+
const methods: Record<string, (...args: unknown[]) => unknown> = {};
|
|
11
|
+
const target = instance as unknown as object;
|
|
12
|
+
for (
|
|
13
|
+
let proto: object | null = target;
|
|
14
|
+
proto && proto !== Object.prototype;
|
|
15
|
+
proto = Reflect.getPrototypeOf(proto)
|
|
16
|
+
) {
|
|
17
|
+
for (const key of Object.getOwnPropertyNames(proto)) {
|
|
18
|
+
if (seen.has(key) || key === "constructor") continue;
|
|
19
|
+
seen.add(key);
|
|
20
|
+
const value = Reflect.get(target, key);
|
|
21
|
+
if (typeof value !== "function") continue;
|
|
22
|
+
methods[key] = value as (...args: unknown[]) => unknown;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return methods;
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { deserializeError, type SerializedError } from "@statewalker/webrun-streams";
|
|
2
|
+
import type { Json, JsonObject, RpcMethod } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface NewRpcClientOptions {
|
|
5
|
+
/** Base URL of the RPC endpoint (no trailing slash). */
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
/**
|
|
8
|
+
* Optional fetch override. Defaults to `globalThis.fetch`. Pass a webrun-http
|
|
9
|
+
* handler here to run the client against an in-process server.
|
|
10
|
+
*/
|
|
11
|
+
fetch?: (request: Request) => Promise<Response>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RpcClient {
|
|
15
|
+
/**
|
|
16
|
+
* Load (and cache) the service descriptor and return a proxy object whose
|
|
17
|
+
* methods round-trip through the transport.
|
|
18
|
+
*/
|
|
19
|
+
loadService<T = Record<string, RpcMethod>>(serviceName: string): Promise<T>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build an RPC client that calls services exposed by {@link newRpcServer}.
|
|
24
|
+
*
|
|
25
|
+
* The descriptor at `GET {baseUrl}/` is fetched lazily on the first
|
|
26
|
+
* `loadService` call and cached for subsequent calls.
|
|
27
|
+
*/
|
|
28
|
+
export function newRpcClient({
|
|
29
|
+
baseUrl,
|
|
30
|
+
fetch = globalThis.fetch.bind(globalThis),
|
|
31
|
+
}: NewRpcClientOptions): RpcClient {
|
|
32
|
+
let apiPromise: Promise<Record<string, Record<string, RpcMethod>>> | null = null;
|
|
33
|
+
|
|
34
|
+
const loadApi = async () => {
|
|
35
|
+
const response = await fetch(new Request(baseUrl));
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Failed to load services descriptor: ${response.status} ${response.statusText}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const descriptor = (await response.json()) as Record<string, string[]>;
|
|
42
|
+
const services: Record<string, Record<string, RpcMethod>> = {};
|
|
43
|
+
for (const [serviceName, methodNames] of Object.entries(descriptor)) {
|
|
44
|
+
const serviceApi: Record<string, RpcMethod> = {};
|
|
45
|
+
for (const methodName of methodNames) {
|
|
46
|
+
serviceApi[methodName] = newRpcMethod(fetch, baseUrl, serviceName, methodName);
|
|
47
|
+
}
|
|
48
|
+
services[serviceName] = serviceApi;
|
|
49
|
+
}
|
|
50
|
+
return services;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async loadService<T = Record<string, RpcMethod>>(serviceName: string): Promise<T> {
|
|
55
|
+
if (!apiPromise) apiPromise = loadApi();
|
|
56
|
+
const apis = await apiPromise;
|
|
57
|
+
const service = apis[serviceName];
|
|
58
|
+
if (!service) throw new Error(`Service ${serviceName} not found`);
|
|
59
|
+
return service as T;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function newRpcMethod(
|
|
65
|
+
fetch: (request: Request) => Promise<Response>,
|
|
66
|
+
baseUrl: string,
|
|
67
|
+
serviceName: string,
|
|
68
|
+
methodName: string,
|
|
69
|
+
): RpcMethod {
|
|
70
|
+
return async (params: Json = {}, body?: Blob): Promise<Blob | Json> => {
|
|
71
|
+
const url = `${baseUrl}/${serviceName}/${methodName}`;
|
|
72
|
+
const formData = new FormData();
|
|
73
|
+
formData.append("params", JSON.stringify(params));
|
|
74
|
+
if (body) formData.append("body", body);
|
|
75
|
+
const response = await fetch(new Request(url, { method: "POST", body: formData }));
|
|
76
|
+
const contentType = response.headers.get("Content-Type") || "";
|
|
77
|
+
if (contentType.includes("application/json")) {
|
|
78
|
+
const json = (await response.json()) as JsonObject | null;
|
|
79
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) {
|
|
80
|
+
throw new Error("RPC response is not a JSON object");
|
|
81
|
+
}
|
|
82
|
+
if (json.type === "error") throw deserializeError(json as SerializedError);
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new Error(`RPC call failed: ${response.status} ${response.statusText}`);
|
|
85
|
+
}
|
|
86
|
+
return json.result as Json;
|
|
87
|
+
}
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
throw new Error(`RPC call failed: ${response.status} ${response.statusText}`);
|
|
90
|
+
}
|
|
91
|
+
return response.blob();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { serializeError } from "@statewalker/webrun-streams";
|
|
2
|
+
import { getInstanceMethods } from "./get-instance-methods.js";
|
|
3
|
+
import type { Json, JsonObject, RpcMethod } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface NewRpcServerOptions {
|
|
6
|
+
/**
|
|
7
|
+
* URL prefix under which the RPC endpoints are mounted. A trailing slash is
|
|
8
|
+
* stripped. Empty (the default) mounts at the origin root.
|
|
9
|
+
*/
|
|
10
|
+
path?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type ServiceIndex = Record<string, Record<string, RpcMethod>>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a webrun-http `(Request) ⇒ Response` handler that exposes every method
|
|
17
|
+
* of every service as an HTTP endpoint.
|
|
18
|
+
*
|
|
19
|
+
* Wire format:
|
|
20
|
+
* - `GET {path}/` → `{ [service]: [methodName, ...] }`
|
|
21
|
+
* - `GET {path}/{service}` → `[methodName, ...]`
|
|
22
|
+
* - `GET {path}/{service}/{method}?a.b=c` → calls the method with `{ a: { b: "c" } }`
|
|
23
|
+
* - `POST {path}/{service}/{method}` with multipart/form-data (`params` JSON
|
|
24
|
+
* + optional `body` Blob) → calls the method with the decoded args
|
|
25
|
+
*
|
|
26
|
+
* Results are returned as `{ type: "json", result }` JSON, or as an
|
|
27
|
+
* `application/octet-stream` blob when the method returns a `Blob`. Errors
|
|
28
|
+
* are serialized to `{ type: "error", message, stack, ... }` JSON.
|
|
29
|
+
*/
|
|
30
|
+
export function newRpcServer(
|
|
31
|
+
services: Record<string, object>,
|
|
32
|
+
{ path = "" }: NewRpcServerOptions = {},
|
|
33
|
+
): (request: Request) => Promise<Response> {
|
|
34
|
+
const prefix = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
35
|
+
const index = buildServiceIndex(services);
|
|
36
|
+
|
|
37
|
+
return async (request: Request): Promise<Response> => {
|
|
38
|
+
try {
|
|
39
|
+
const route = splitRequestPath(request, prefix);
|
|
40
|
+
if (!route) return errorResponse(new Error("Not found"), 404);
|
|
41
|
+
const { serviceName, methodName, subPath } = route;
|
|
42
|
+
if (request.method === "GET" && !serviceName) {
|
|
43
|
+
return jsonResponse(describeServices(index));
|
|
44
|
+
}
|
|
45
|
+
if (request.method === "GET" && serviceName && !methodName) {
|
|
46
|
+
return jsonResponse(listMethods(index, serviceName));
|
|
47
|
+
}
|
|
48
|
+
if ((request.method === "GET" || request.method === "POST") && serviceName && methodName) {
|
|
49
|
+
const { params, body } = await parseCallArgs(request);
|
|
50
|
+
const result = await invoke(index, serviceName, methodName, subPath, params, body);
|
|
51
|
+
return result instanceof Blob ? blobResponse(result) : jsonResponse(result);
|
|
52
|
+
}
|
|
53
|
+
return errorResponse(new Error("Not found"), 404);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return errorResponse(error as Error, 500);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildServiceIndex(services: Record<string, object>): ServiceIndex {
|
|
61
|
+
const index: ServiceIndex = {};
|
|
62
|
+
for (const [serviceName, service] of Object.entries(services)) {
|
|
63
|
+
const methods = getInstanceMethods(service);
|
|
64
|
+
const methodsIndex: Record<string, RpcMethod> = {};
|
|
65
|
+
for (const [methodName, method] of Object.entries(methods)) {
|
|
66
|
+
methodsIndex[methodName] = async (params, body) =>
|
|
67
|
+
(await method.call(service, params, body)) as Blob | Json;
|
|
68
|
+
}
|
|
69
|
+
index[serviceName] = methodsIndex;
|
|
70
|
+
}
|
|
71
|
+
return index;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ParsedPath {
|
|
75
|
+
serviceName?: string;
|
|
76
|
+
methodName?: string;
|
|
77
|
+
subPath: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function splitRequestPath(request: Request, prefix: string): ParsedPath | null {
|
|
81
|
+
const pathname = new URL(request.url).pathname;
|
|
82
|
+
if (prefix && !pathname.startsWith(prefix)) return null;
|
|
83
|
+
const rest = pathname.substring(prefix.length + 1);
|
|
84
|
+
const [serviceName = "", methodName = "", ...tail] = rest.split("/");
|
|
85
|
+
return {
|
|
86
|
+
serviceName: serviceName || undefined,
|
|
87
|
+
methodName: methodName || undefined,
|
|
88
|
+
subPath: tail.join("/"),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function parseCallArgs(request: Request): Promise<{ params: Json; body?: Blob }> {
|
|
93
|
+
if (request.method === "GET") {
|
|
94
|
+
return { params: parseQueryParams(new URL(request.url).searchParams) };
|
|
95
|
+
}
|
|
96
|
+
const contentType = request.headers.get("Content-Type") || "";
|
|
97
|
+
if (contentType.startsWith("multipart/form-data")) {
|
|
98
|
+
const formData = await request.formData();
|
|
99
|
+
let params: Json = {};
|
|
100
|
+
if (formData.has("params")) {
|
|
101
|
+
params = JSON.parse(formData.get("params") as string) as Json;
|
|
102
|
+
}
|
|
103
|
+
const body = formData.has("body") ? (formData.get("body") as Blob) : undefined;
|
|
104
|
+
return { params, body };
|
|
105
|
+
}
|
|
106
|
+
const body = request.body ? await request.blob() : undefined;
|
|
107
|
+
return { params: {}, body };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Expand dot-separated query keys (`a.b=c`) into nested JSON objects.
|
|
112
|
+
* Repeated keys overwrite rather than merging — same as the original.
|
|
113
|
+
*/
|
|
114
|
+
function parseQueryParams(search: URLSearchParams): JsonObject {
|
|
115
|
+
const root: JsonObject = {};
|
|
116
|
+
for (const [key, value] of search.entries()) {
|
|
117
|
+
const segments = key.split(".");
|
|
118
|
+
let cursor: JsonObject = root;
|
|
119
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
120
|
+
const seg = segments[i];
|
|
121
|
+
const existing = cursor[seg];
|
|
122
|
+
if (typeof existing !== "object" || existing === null || Array.isArray(existing)) {
|
|
123
|
+
cursor[seg] = {};
|
|
124
|
+
}
|
|
125
|
+
cursor = cursor[seg] as JsonObject;
|
|
126
|
+
}
|
|
127
|
+
cursor[segments[segments.length - 1]] = value;
|
|
128
|
+
}
|
|
129
|
+
return root;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function invoke(
|
|
133
|
+
index: ServiceIndex,
|
|
134
|
+
serviceName: string,
|
|
135
|
+
methodName: string,
|
|
136
|
+
subPath: string,
|
|
137
|
+
params: Json,
|
|
138
|
+
body?: Blob,
|
|
139
|
+
): Promise<Json | Blob> {
|
|
140
|
+
if (params && typeof params === "object" && !Array.isArray(params)) {
|
|
141
|
+
(params as JsonObject).$path = subPath;
|
|
142
|
+
}
|
|
143
|
+
const method = index[serviceName]?.[methodName];
|
|
144
|
+
if (!method) {
|
|
145
|
+
throw new Error(`Method ${methodName} not found in service ${serviceName}`);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const result = await method(params, body);
|
|
149
|
+
return result instanceof Blob ? result : ({ type: "json", result } as JsonObject);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return { type: "error", ...serializeError(error as Error) } as JsonObject;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function describeServices(index: ServiceIndex): JsonObject {
|
|
156
|
+
const out: JsonObject = {};
|
|
157
|
+
for (const [name, methods] of Object.entries(index)) {
|
|
158
|
+
out[name] = Object.keys(methods);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function listMethods(index: ServiceIndex, serviceName: string): Json[] {
|
|
164
|
+
const svc = index[serviceName];
|
|
165
|
+
return svc ? Object.keys(svc) : [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function jsonResponse(body: Json, status = 200): Response {
|
|
169
|
+
return new Response(JSON.stringify(body), {
|
|
170
|
+
status,
|
|
171
|
+
headers: { "Content-Type": "application/json" },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function blobResponse(body: Blob): Response {
|
|
176
|
+
return new Response(body, {
|
|
177
|
+
status: 200,
|
|
178
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function errorResponse(error: Error, status: number): Response {
|
|
183
|
+
const body: JsonObject = { type: "error", ...serializeError(error) };
|
|
184
|
+
return new Response(JSON.stringify(body), {
|
|
185
|
+
status,
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
});
|
|
188
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Any value that can survive a JSON round-trip. */
|
|
2
|
+
export type Json = string | number | boolean | null | Json[] | JsonObject;
|
|
3
|
+
|
|
4
|
+
/** A JSON object — the only valid shape for RPC call params. */
|
|
5
|
+
export interface JsonObject {
|
|
6
|
+
[key: string]: Json;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An RPC method. Takes a JSON-shaped `params` argument and an optional binary
|
|
11
|
+
* `body`, returns a JSON value or a `Blob`.
|
|
12
|
+
*/
|
|
13
|
+
export type RpcMethod = (params: Json, body?: Blob) => Promise<Blob | Json>;
|