"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.ts
import 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.request
is the http node request object (with headers and stuff)meta.ip
is the ip address associated with the request- for input validation, you should use zod or something
- π make an http server β
server.ts
import 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 /health
route 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
RequestListener
for 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.ts
import 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.ts
import 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.ts
import Renraku from "@e280/renraku" import {serverside} from "./serverside.js" await new Renraku.Server({websocket: serverside}) .listen(8000)
- π connect as a client β
client.ts
import 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
connection
object 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:
WsIntegration
provides anupgrader
that 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 error
to the clientconst timingApi = { async now() { throw new Error("not enough minerals") // βοΈ // secret message is hidden from remote clients }, }
- but you can throw an
ExposedError
when 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
secure
function 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
authorize
function 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?
secure
andauthorize
do not support arbitrary nesting, so you have to pass them a flat object of async functions
- all the functions on a renraku
Remote
can be 'tuned' - import the symbol
import {tune} from "@e280/renraku"
- imagine we have some renraku remote
await remote.sum(1, 2) // 3
tune
a call withnotify
await 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
tune
a call withtransfer
const 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"
settings
to configurenotify
permanently on a fnnow future calls will useawait remote.sum[settings].notify = true
notify: true
(unlesstune
overrides)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 usewsConnect
helper 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
remote
and 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>
β ensuresX
is a group of valid async functionsRemote<MyFns>
β adds the magictune
stuff to the providedMyFns
types
π free and open source just for you
π reward us with github stars
π» join us at e280 if you're a real one