@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 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"}
@@ -0,0 +1,5 @@
1
+ export * from "./get-instance-methods.js";
2
+ export * from "./new-rpc-client.js";
3
+ export * from "./new-rpc-server.js";
4
+ export * from "./types.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -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"}
@@ -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,4 @@
1
+ export * from "./get-instance-methods.js";
2
+ export * from "./new-rpc-client.js";
3
+ export * from "./new-rpc-server.js";
4
+ export * from "./types.js";
@@ -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>;