"an api should just be a bunch of async functions, damn it!"
— Chase Moskal, many years ago
renraku is a magic typescript json-rpc library that makes life joyous again.
📦 npm install @e280/renraku
💡 async functions as api
🔌 http, websockets, postmessage, anything
🌐 node + browser
🏛️ json-rpc 2.0
🤖 for web workers, see comrade
💻 an https://e280.org/ project
- 🍏 your api is just async functions —
rpc.tsimport Renraku from "@e280/renraku" export type MyFns = Awaited<ReturnType<typeof myRpc>> export const myRpc = Renraku.asRpc(async meta => ({ async now() { return Date.now() }, async add(a: number, b: number) { return a + b }, nesty: { is: { besty: { async mul(a: number, b: number) { return a * b }, }, }, }, }))
meta.requestis the http node request object (with headers and stuff)meta.ipis the ip address associated with the request- for input validation, you should use zod or something
- 🍏 make an http server —
server.tsimport Renraku from "@e280/renraku" import {myRpc} from "./rpc.js" await new Renraku.Server({rpc: myRpc}) .listen(8000)
- your functions are served on a
POST /json-rpc 2.0 endpoint - you get a free
GET /healthroute that returns the current js timestamp
- your functions are served on a
- 🍏 make a clientside remote —
client.ts🪄 now you can magically call the functions on the clientsideimport Renraku from "@e280/renraku" import type {MyFns} from "./rpc.js" const remote = Renraku.httpRemote<MyFns>({url: "http://localhost:8000/"})
await remote.now() // 1753780093703 await remote.add(2, 2) // 4 await remote.nesty.is.besty.mul(2, 3) // 6
if you're feeling spartan, you can produce an ordinary node http
RequestListenerfor your rpc functions:import Renraku from "@e280/renraku" import * as http from "node:http" import {myRpc} from "./rpc.js" const requestListener = Renraku.makeRequestListener({rpc: myRpc}) new http.Server(requestListener) .listen(8000)
renraku websocket apis are bidirectional, meaning the serverside and clientside can call each other.. just be careful not to create a circular loop, lol..
and yes — a single renraku server can support an http rpc endpoint and a websocket api simultaneously.
- 🍏 make your serverside —
serverside.tsimport Renraku from "@e280/renraku" import type {Clientside} from "./clientside.js" export type Serverside = { now(): Promise<number> } export const serverside = ( Renraku.asAccepter<Serverside, Clientside>(async connection => { console.log("connected", connection.ip) return { fns: { async now() { // 🫨 omg we're calling the clientside from the serverside! await connection.remote.sum(1, 2) return Date.now() }, }, disconnected() { console.log("disconnected", connection.ip) }, } }) )
- 🍏 make your clientside —
clientside.tsimport Renraku from "@e280/renraku" import type {Serverside} from "./serverside.js" export type Clientside = { sum(a: number, b: number): Promise<number> } export const clientside = ( Renraku.asConnector<Clientside, Serverside>(async connection => { console.log("connected") return { fns: { async sum(a: number, b: number) { return a + b }, }, disconnected() { console.log("disconnected") }, } }) )
- 🍏 run the websocket server —
server.tsimport Renraku from "@e280/renraku" import {serverside} from "./serverside.js" await new Renraku.Server({websocket: serverside}) .listen(8000)
- 🍏 connect as a client —
client.tsimport Renraku from "@e280/renraku" import {clientside} from "./clientside.js" const connection = await Renraku.wsConnect({ connector: clientside, socket: new WebSocket("ws://localhost:8000/"), }) // call the serverside functionality const result = await connection.remote.now() // 1753738662615 // get the average ping time in milliseconds connection.rtt.average // 99 // kill the connection connection.close()
- 🍏 the
connectionobject has a bunch of good stuff- all connection objects have this stuff:
connection.socket // raw websocket instance connection.rtt.latest // latest known ping time in milliseconds connection.rtt.average // average of a handful of latest ping results connection.rtt.on(rtt => {}) // subscribe to individual ping results // remote for calling fns on the other side await connection.remote.sum(1, 2) // kill this connection connection.close()
- serverside connections also have HttpMeta stuff:
connection.ip // ip address of the client connection.request // http request with headers and such
- all connection objects have this stuff:
WsIntegrationprovides anupgraderthat you can plug into a stock node http server:import Renraku from "@e280/renraku" import * as http from "node:http" import {serverside} from "./serverside.js" const server = new http.Server() const websockets = new Renraku.WsIntegration({accepter: serverside}) server.on("upgrade", websockets.upgrader)
new Renraku.Server({
// expose http json-rpc api
rpc: async meta => ({
async hello() { return "lol" },
}),
// expose websocket json-rpc api
websocket: Renraku.asAccepter<Serverside, Clientside>(
async connection => ({
fns: {async hello() { return "lmao" }},
disconnected() {},
})
),
// supply a logger to get verbose console output (only logs errors by default)
tap: new Renraku.LoggerTap(),
// allow cross-origin requests (cors is disabled by default)
cors: {origins: "*"},
// request timeout in milliseconds (defaults to 60_000)
timeout: 60_000,
// requests with bodies bigger than this number are rejected (10 MB default)
maxRequestBytes: 10_000_000,
// specify the url of the rpc endpoint (defaults to `/`)
rpcRoute: "/",
// specify the url of the health endpoint (defaults to `/health`)
healthRoute: "/health",
// provide a transmuter that modifies incoming requests before routing
transmuters: [],
// you can provide custom listeners for additional http routes..
routes: [
Renraku.route.get("/hello", Renraku.respond.text("hello world")),
],
})- renraku has this concept of a
Tap, which allows you to hook into renraku for logging purposes - almost every renraku facility, can accept a
tap— likemakeRemote,makeEndpoint, etcErrorTap(default) — logs errors, but not every requestLoggerTap— (default forServer) verbose logging, all errors and every requestDudTap— silent, doesn't log anything
- for security-by-default, when renraku encounters an error, it reports
unexposed errorto the clientconst timingApi = { async now() { throw new Error("not enough minerals") // ☝️ // secret message is hidden from remote clients }, }
- but you can throw an
ExposedErrorwhen you want the error message sent to the clientimport {ExposedError} from "@e280/renraku" const timingApi = { async now() { throw new ExposedError("insufficient vespene gas") // ☝️ // publicly visible message }, }
- any other kind of error will NOT send the message to the client
- the intention here is security-by-default, because error messages could potentially include sensitive information
- use the
securefunction to section off parts of your api that require auth// auth param can be any type you want const secured = Renraku.secure(async(auth: string) => { // here you can do any auth work you need if (auth !== "hello") throw new Error("auth error: did not receive warm greeting") return { async sum(a: number, b: number) { return a + b }, } }) // 'secure' augments the functions to require the 'auth' param first await secured.sum("hello", 1, 2)
- use the
authorizefunction on the clientside to provide the auth param upfrontconst authorized = Renraku.authorize(secured, async() => "hello") // now the auth is magically provided for each call await authorized.sum(1, 2)
- but why an async getter function?
because it's a perfect opportunity for you to refresh tokens or what-have-you.
the getter is called for each api call.
- but why an async getter function?
secureandauthorizedo not support arbitrary nesting, so you have to pass them a flat object of async functions
- all the functions on a renraku
Remotecan be 'tuned' - import the symbol
import {tune} from "@e280/renraku"
- imagine we have some renraku remote
await remote.sum(1, 2) // 3
tunea call withnotifyawait remote.sum[tune]({notify: true})(1, 2) // undefined
- this is how we do a json-rpc protocol 'notification' request, which skips the response (for fire-and-forget actions)
- sometimes responses are not needed, so this can be a nice little optimization
tunea call withtransferconst buffer = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]).buffer await remote.deliver[tune]({transfer: [buffer]})(buffer)
- this is how we specify transferables for fast zero-copy transfers between worker threads and such
- important in sister project comrade for threading workloads
- it's a set-and-forget way to pre-configure the default behavior for a remote fn
- import the symbol
import {settings} from "@e280/renraku"
settingsto configurenotifypermanently on a fnnow future calls will useawait remote.sum[settings].notify = true
notify: true(unlesstuneoverrides)await remote.sum(1, 2) // undefined
Messenger is a bidirectional-capable api mediator, though it can also be used in a one-way capacity.
Conduit subclasses facilitate communications over various mediums:
BroadcastConduit— for broadcast channelPostableConduit— for post message channels like web workersWindowConduit— for window post message channelsWebsocketConduit— used under the hood for websockets (but you should usewsConnecthelper instead)
the following examples will demonstrate using Messengers with WindowConduits for a common popup api example.
+----ALPHA----+ +----BRAVO----+
| | | |
| [Conduit]<==========>[Conduit] |
| | | | | |
| [Messenger] | | [Messenger] |
| | | |
+-------------+ +-------------+
- "alpha and bravo" could be a "clientside and serverside" or "window and popup" or whatever
- the point is, each side gets its own conduit and its own messenger
- the conduits are literally talking to each other
- the messenger's job is to deal with json-rpc and provide you with a callable
remoteand execute your local rpc endpoint
api.ts— make a popup apiimport Renraku from "@e280/renraku" export const appOrigin = "https://example.e280.org" export type PopupFns = Awaited<ReturnType<typeof popupRpc>> export const popupRpc = Renraku.asMessengerRpc(async meta => ({ async sum(a: number, b: number) { return a + b }, }))
popup.ts— in the popup, we create a messenger to expose our fnsimport Renraku from "@e280/renraku" import {popupRpc, appOrigin} from "./api.js" const messenger = new Renraku.Messenger({ rpc: popupRpc, conduit: new Renraku.conduits.WindowConduit({ localWindow: window, targetWindow: window.opener, targetOrigin: appOrigin, allow: e => e.origin === appOrigin, }), })
parent.ts— in the parent window, we create a messenger to call our fnsnow we can call the popup's fns:import Renraku from "@e280/renraku" import {PopupFns, appOrigin} from "./api.js" const popup = window.open(`${appOrigin}/popup`) const messenger = new Renraku.Messenger<PopupFns>({ conduit: new Renraku.conduits.WindowConduit({ localWindow: window, targetWindow: popup, targetOrigin: appOrigin, allow: e => e.origin === appOrigin, }), })
await messenger.remote.sum(2, 3) // 5
api.ts— make both apisimport Renraku from "@e280/renraku" export const appOrigin = "https://example.e280.org" export type PopupFns = {sum(a: number, b: number): Promise<number>} export type ParentFns = {mul(a: number, b: number): Promise<number>} export const popupRpc = Renraku.asMessengerRpc<PopupFns, ParentFns>(async meta => ({ async sum(a, b) { await meta.remote.mul(2, 3) // 🧐 yes, we can call the other side return a + b }, })) export const parentRpc = Renraku.asMessengerRpc<ParentFns, PopupFns>(async meta => ({ async mul(a, b) { return a * b }, }))
popup.ts— popup window sidenow the popup can call parent fnsimport Renraku from "@e280/renraku" import {appOrigin, popupRpc} from "./api.js" const messenger = new Renraku.Messenger({ rpc: popupRpc, conduit: new Renraku.conduits.WindowConduit({ localWindow: window, targetWindow: window.opener, targetOrigin: appOrigin, allow: e => e.origin === appOrigin, }), })
await messenger.remote.mul(2, 3) // 6
parent.ts— parent window sidenow the parent can call popup fnsimport Renraku from "@e280/renraku" import {appOrigin, parentRpc} from "./api.js" const popup = window.open(`${appOrigin}/popup`) const messenger = new Renraku.Messenger({ rpc: parentRpc, conduit: new Renraku.conduits.WindowConduit({ localWindow: window, targetWindow: popup, targetOrigin: appOrigin, allow: e => e.origin === appOrigin, }), })
await messenger.remote.sum(2, 3) // 5
Messenger is often used across postMessage boundaries, to talk to popups, iframes, or web workers.
as such, you can set meta.transfer array, so you can return transferables:
export const popupRpc = Renraku.asMessengerRpc(async meta => ({
async getData() {
const bytes = new Uint8Array([0xB0, 0x0B, 0x1E, 0x5]).buffer
meta.transfer = [bytes]
return bytes // ⚡ transferred speedy-fastly
},
}))- TODO lol we should write more in depth docs about the core tools here
makeEndpoint(~)— make a json-rpc endpoint fn for a group of async fnsmakeRemote(~)— make a nested proxy tree of invokable fns, given an endpointmakeMock(~)— sugar for making an endpoint and then a remote for the given fnsJsonRpc— namespace of json rpc types and helpersfns(~)— typescript identity helper for a group of async fnstypes.ts— typescript identity helper for a group of async fnsAsFns<X>— ensuresXis a group of valid async functionsRemote<MyFns>— adds the magictunestuff to the providedMyFnstypes
💖 free and open source just for you
🌟 reward us with github stars
💻 join us at e280 if you're a real one