npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 ceremonyrpc.a.b.c(params) → method "a.b.c".
  • Result model{ response, refused }, error-first tuple too; expected outcomes returned, only the exceptional throws. No try/catch in business logic.
  • One dependency (json-rpc-2.0). Uses native fetch — 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.
  • Streamingfor await (const delta of rpc.stream(…)), with backpressure and cancellation. Great for LLM token streams.
  • Attachmentsrpc.upload(file) → an opaque reference for RPC calls; rpc.download(ref). Storage-agnostic, with progress.
  • Lifecycle hookson("callEnd", …) for logging/metrics/devtools.
  • Dual ESM + CJSimport or require.

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, or null (null = "nothing there"; absence is success, not an error).
  • refused — an expected rejection { message, fields? }, or null — 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 globex

Each 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 incrementally

Wire contract (for server implementers)

UploadPOST <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 }.

DownloadGET <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 / errorcallEnd. For streams: streamStartstreamChunk* → streamEnd (or error). For uploads: uploadStartuploadProgress* → 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