@future-unknown/methodry
v0.6.0
Published
Tiny JSON-RPC 2.0 client with Proxy-sugar method calls, pluggable auth, batching, and lifecycle hooks. Works in browsers, Node, and CLIs.
Readme
methodry
A tiny JSON-RPC 2.0 client where method calls are just property access. Methods you never wrote, conjured on access.
const rpc = createClient("https://api.example.com", token)
const { response: org, refused } = await rpc.orgs.get({ id: "acme" })
if (refused) return show(refused.message) // validation / authorization / "no"
if (!org) return showEmpty() // null response = nothing there
use(org)Property chains map straight to dotted JSON-RPC method names — rpc.orgs.members.add(params) calls the method "orgs.members.add" with a single named-params object. No codegen, no method catalog, no schema step. Works in browsers, Node, and CLIs with one dependency.
Calls return a result, not a bare value: expected outcomes (data, absence, refusal) come back as values you branch on; only the exceptional (network, server, timeout, unrecoverable auth) throws. You never try/catch in business logic. See the result model for the full design.
Why
JSON-RPC 2.0 is a clean wire protocol, but most clients make you either hand-build envelopes or generate a client from a schema. This library uses a Proxy so any method name — at any nesting depth — just works, while still giving you batching, pluggable auth with transparent token refresh, and lifecycle hooks for observability.
- Zero ceremony —
rpc.a.b.c(params)→ method"a.b.c". - Result model —
{ response, refused }, error-first tuple too; expected outcomes returned, only the exceptional throws. Notry/catchin business logic. - One dependency (
json-rpc-2.0). Uses nativefetch— no axios, no polyfills. - Pluggable auth — bearer by default; cookie sessions, API keys, and custom schemes via options.
- Batching — many calls, one HTTP round-trip, id-correlated.
- Streaming —
for await (const delta of rpc.stream(…)), with backpressure and cancellation. Great for LLM token streams. - Attachments —
rpc.upload(file)→ an opaque reference for RPC calls;rpc.download(ref). Storage-agnostic, with progress. - Lifecycle hooks —
on("callEnd", …)for logging/metrics/devtools. - Dual ESM + CJS —
importorrequire.
Install
methodry is distributed via git tags from its private repo — it is not published to npm at this time. Depend on it by tag for reproducible installs:
npm install git+ssh://[email protected]/future-unknown/methodry.git#v0.5.0// package.json
"dependencies": {
"methodry": "git+ssh://[email protected]/future-unknown/methodry.git#v0.5.0"
}The package builds itself on install (a prepare script runs the bundler), so a
git dependency needs no separate build step. To upgrade, bump the tag.
Requires an environment with fetch, AbortController, and Proxy — all modern browsers and Node 18+. (Provide a fetch via options to run on older Node.)
Usage
// ESM
import { createClient } from "methodry"
// CommonJS
const { createClient } = require("methodry")
const rpc = createClient("https://api.example.com", "access-token-123")
// object form
const { response: orgs } = await rpc.orgs.list()
// error-first tuple form (the result is iterable as [refused, response])
const [refused, org] = await rpc.orgs.get({ id: "acme" })Each call POSTs a JSON-RPC 2.0 request to <endpoint>/rpc and resolves to a
result { response, refused, method, ms }:
response— the unwrapped result, ornull(null= "nothing there"; absence is success, not an error).refused— an expected rejection{ message, fields? }, ornull— validation, authorization, business-rule "no"s. Returned, not thrown.
Only the exceptional throws — a MethodryError with a code of "network", "timeout", "canceled", "server", or "unauthorized" (the last when reauthenticate can't recover). Catch those at one boundary; never in business logic.
const { response: user, refused } = await rpc.users.create(form)
if (refused) {
if (refused.fields) return paintFields(refused.fields) // form, per-field
return toast(refused.message) // authz / business rule
}
welcome(user)See docs/result-model.md for the complete model, including the wire contract servers implement (data.refused marks a refusal).
Escape hatch
For method names that aren't valid property chains:
await rpc.call("weird-method/name", { query: "ac" })Auth & token refresh
reauthenticate is the auth handler — pass it to get a fresh token when the server
returns an auth-failure status (401 by default), and onToken to persist it.
In-flight calls that all fail at once trigger one refresh, then retry. It can
do a silent refresh or block on an interactive login for as long as it needs;
methodry just awaits it, so auth recovery is effectively indefinite across the
session. When reauthenticate ultimately can't deliver a token, the call throws
MethodryError { code: "unauthorized" } at your boundary — there's no separate
onUnauthorized hook, because reauthenticate already owns auth.
const rpc = createClient(endpoint, savedToken, {
reauthenticate: async () => await refreshAccessToken(), // or: redirect to login and await it
onToken: (token) => saveToken(token)
})Cookie sessions / API keys / custom schemes
Auth is just headers, so override how they're built:
// Cookie session (browser): send credentials, no auth header
createClient(endpoint, null, { credentials: "include", authHeader: () => ({}) })
// API key header instead of Bearer
createClient(endpoint, key, { authHeader: (k) => ({ "x-api-key": k }) })
// Dynamic per-request headers (tracing, tenant, …)
createClient(endpoint, token, { headers: ({ payload }) => ({ "x-trace": newTraceId() }) })Batching
Collect calls and send them in a single HTTP request. Each accumulated call
returns its own promise; exec() fires one JSON-RPC batch and the responses fan
back out, correlated by id:
const batch = rpc.batch()
const acme = batch.orgs.get({ id: "acme" })
const globex = batch.orgs.get({ id: "globex" })
await batch.exec() // one round-trip
const { response: a } = await acme // each accumulated call resolves to a result
const { response: g } = await globexEach accumulated call resolves to its own result ({ response, refused }). A
refusal comes back on that call's result; an operational failure rejects it (and
fails the batch). The whole batch travels in one HTTP request, id-correlated.
Streaming
For methods that emit a stream of partial results — an LLM generating tokens,
say — rpc.stream(method, params) returns an async iterable. for await
applies backpressure automatically (the next chunk is only pulled when you ask),
and breaking the loop aborts the request, stopping the upstream generation:
for await (const delta of rpc.stream("llm.complete", { prompt, model })) {
process.stdout.write(delta.text)
if (userHitStop) break // aborts the request, halts generation
}The same call is available as Proxy sugar on any method namespace, matching
the rpc.a.b.c() style of regular calls:
for await (const delta of rpc.llm.complete.$stream({ prompt, model })) { … }
// identical to rpc.stream("llm.complete", { prompt, model })$stream is a reserved leaf; an API method literally ending in .$stream (rare)
stays reachable via rpc.stream(…) or rpc.call(…).
The wire format is Server-Sent Events: the client POSTs the JSON-RPC envelope
with Accept: text/event-stream, then reads response.body — each data: line
is parsed as JSON, [DONE] ends the stream, and an { error } chunk throws.
Chunks shaped { result: … } are unwrapped; anything else is yielded as-is, so a
raw LLM passthrough works without a wrapper.
When the server frames chunks differently (NDJSON, raw text, a custom envelope),
pass a parse function as the third argument — it receives each event's data
string and returns the chunk ({ done: true } to end, { error } to throw):
for await (const c of rpc.stream("gen", params, {
parse: (data) => (data === "[DONE]" ? { done: true } : { text: data })
})) { /* … */ }Streaming is built on the Web Streams API + TextDecoder, so it runs unchanged
in browsers and Node 18+. (The buffered timeout does not apply to streams — a
stream lives until it completes or you break out of the loop.)
Attachments (upload & download)
Binary doesn't fit JSON-RPC, so attachments use a dedicated route.
rpc.upload(body, opts) POSTs the raw bytes and returns the server's reference
object — opaque to methodry, because the server decides where the bytes land
(S3, a NATS object bucket, the filesystem, …). You thread that reference (or its
id) into a normal RPC call:
const { response: att } = await rpc.upload(file, { name: "report.pdf", contentType: "application/pdf" })
// att = { id: "att_7Qx…", name, size, content_type, checksum, backend }
await rpc.invoices.attach({ invoiceId, attachmentId: att.id })upload and download resolve to a result like any other call — response is
the reference (upload) or the live Response (download).
Body types: Blob/File, ArrayBuffer, typed arrays, string, or a
ReadableStream. In Node, pass a Buffer or await openAsBlob(path).
Metadata: pass small structured metadata that travels with the upload — it's JSON-encoded into a header for the server to read:
await rpc.upload(file, { name: "report.pdf", metadata: { caption: "Q2", tags: ["finance"] } })Progress is best-effort via onProgress — it engages where the runtime
supports streaming request bodies (Node 18+, Chromium) and degrades to start/end
elsewhere. Using it makes the body non-replayable, so a token expiry mid-upload
surfaces a "retry the upload" error instead of silently re-sending:
await rpc.upload(file, {
name: "big.zip",
onProgress: ({ loaded, total }) => bar.update(loaded / total)
})Download by id or reference and get the live Response back — stream it,
buffer it, or save it:
const { response: res } = await rpc.download(att.id) // or rpc.download(att)
const blob = await res.blob() // or read res.body incrementallyWire contract (for server implementers)
Upload — POST <uploadPath> (default /upload) with the raw bytes as the
request body and these headers:
| Header | Value |
| -------------------- | --------------------------------------------------------------- |
| Content-Type | the body's MIME type (opts.contentType, else application/octet-stream) |
| X-Upload-Filename | opts.name, percent-encoded (encodeURIComponent); omitted if no name |
| X-Upload-Size | byte length, when known (omitted for unsized streams) |
| X-Upload-Metadata | opts.metadata as base64-encoded JSON (UTF-8); omitted if none |
Plus whatever auth headers the client is configured for (Authorization: Bearer …
by default). Respond 200 with a JSON reference object — its schema is
yours; methodry treats it as opaque and returns it verbatim. A common shape is
{ id, name, size, content_type, checksum, backend }.
Download — GET <downloadPath>/<id> (default /attachments/<id>, the id
percent-encoded), same auth. Respond with the bytes (any Content-Type). If a
reference carries an explicit url, the client GETs that URL directly instead.
Both routes reuse the same auth + reauthentication as RPC calls. The dedicated route keeps the client storage-agnostic — the same code works whichever backend the server writes to.
Lifecycle hooks
Subscribe for logging, metrics, or devtools without wrapping call sites:
rpc.on("callEnd", ({ method, ms, error }) => {
metrics.timing(`rpc.${method}`, ms, { ok: !error })
})| Event | Payload | When |
| -------------- | -------------------------------------------------- | ----------------------------- |
| callStart | { method, params } | before each logical call |
| success | { method, params, response, ms } | a call returned a response |
| refused | { method, params, refused, ms } | a call returned a refusal |
| error | { method, params, error, ms } | a call threw (operational/auth)|
| callEnd | { method, params, result?, error?, ms } | a call finished (terminal) |
| requestStart | { batch, calls } | before a batch request |
| requestEnd | { batch, ms } | after a batch request |
| streamStart | { method, params, stream } | a stream opened |
| streamChunk | { method, params, stream, chunk } | each streamed chunk |
| streamEnd | { method, params, stream, ms } | a stream finished |
| uploadStart | { name, contentType } | an upload began |
| uploadProgress | { name, loaded, total? } | upload bytes flushed (opt-in) |
| uploadEnd | { name, response, ms } | an upload finished |
| downloadStart| { ref } | a download began |
| downloadEnd | { ref, ms } | a download finished |
Order per call: callStart → one of success / refused / error → callEnd.
For streams: streamStart → streamChunk* → streamEnd (or error). For
uploads: uploadStart → uploadProgress* → uploadEnd (or error). A throwing
hook never breaks the underlying call. once and off are also available.
Options
createClient(endpoint, token?, options?)
| Option | Type | Default | Purpose |
| ------------- | --------------------------------------------- | ------------------ | -------------------------------------------------- |
| reauthenticate | () => Promise<string \| null> | — | Refresh and return a fresh token on auth failure. |
| onToken | (token) => void | — | Persist a token produced by reauthenticate. |
| path | string | "/rpc" | JSON-RPC request path appended to the endpoint. |
| uploadPath | string | "/upload" | Route for rpc.upload(). |
| downloadPath| string | "/attachments" | Route for rpc.download() (joined with the id). |
| fetch | typeof fetch | global fetch | Custom fetch (server pooling, older Node, tests). |
| timeout | number | 30000 | Per-request timeout in ms. |
| headers | object \| (({ payload }) => object) | — | Extra headers, static or per-request. |
| authHeader | (token) => object | Bearer <token> | Build the auth header(s). |
| reauthenticateOn | number[] | [401] | HTTP statuses that trigger reauthentication. |
| credentials | RequestCredentials | — | Fetch credentials mode, e.g. "include". |
| mode | RequestMode | — | Fetch CORS mode. |
rpc.stream(method, params, { parse }) (and its sugar rpc.a.b.$stream(params,
{ parse })) take an optional parse override for non-default chunk framing. The
client also exposes rpc.endpoint, rpc.token, rpc.call, rpc.batch,
rpc.stream, rpc.upload, rpc.download, and rpc.on / rpc.once / rpc.off.
License
MIT
