📦 npm i renraku
💡 elegantly expose async functions as an api
🌐 node and browser
🏛️ json-rpc 2.0
🔌 http, websockets, and more
🚚 super transport agnostic
🛡️ beautiful little auth helpers
"an api should just be a bunch of async functions, damn it"
i had this idea in 2017, and have been evolving the implementation and typescript ergonomics ever since.
maybe this project is my life's work, actually...
- install renraku into your project
npm i renraku
- so, you have a bunch of async functions
// example.ts export const exampleFns = { async now() { return Date.now() }, async sum(a: number, b: number) { return a + b }, }
- expose them on your server as a one-liner
// server.ts import {exampleFns} from "./example.js" import {HttpServer, endpoint} from "renraku" new HttpServer(() => endpoint(exampleFns)).listen(8000)
- on the client, another one-liner, and you can magically call those functions
// client.ts import {httpRemote} from "renraku" import type {exampleFns} from "./example.js" // ↑ // 🆒 we only need the *type* here const example = httpRemote<typeof exampleFns>("http://localhost:8000/") // 🪄 you can now call the functions await example.now() // 1723701145176 await example.sum(1, 2) // 3
- you can use arbitrary object nesting to organize your api
export const exampleFns = { date: { async now() { return Date.now() }, }, numbers: { math: { async sum(a: number, b: number) { return a + b }, }, }, }
- on the remote side, you'll get a natural calling syntax
await example.date.now() await example.numbers.math.sum(1, 2)
- on the remote side, you'll get a natural calling syntax
- renraku will provide the http stuff you need
// 🆒 🆒 🆒 // ↓ ↓ ↓ new HttpServer(({req, ip, headers}) => endpoint({ async sum(a: number, b: number) { console.log(ip, headers["origin"]) return a + b }, })).listen(8000)
- use the
secure
function to section off parts of your api that require authimport {secure} from "renraku" export const exampleFns = { // declaring this area requires auth // | // | auth can be any type you want // ↓ ↓ math: 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 }, } }), }
- you see,
secure
merely adds your initial auth parameter as a required argument to each function// auth param // ↓ await example.math.sum("hello", 1, 2)
- you see,
- use the
authorize
function on the clientside to provide the auth param upfrontimport {authorize} from "renraku" // (the secured area) (async getter for auth param) // ↓ ↓ const math = authorize(example.math, async() => "hello") // it's an async function so you could refresh // tokens or whatever // now the auth is magically provided for each call await math.sum(1, 2)
- but why an async getter function?
ah, well that's because it's a perfect opportunity for you to refresh your tokens or what-have-you.
the getter is called for each api call.
- but why an async getter function?
- here our example websocket setup is more complex because we're setting up two apis that can communicate bidirectionally.
- define your serverside and clientside apis
// ws/apis.js // first, we must declare our api types. // (otherwise, typescript has a fit due to the mutual cross-referencing) export type Serverside = { sum(a: number, b: number): Promise<number> } export type Clientside = { now(): Promise<number> } // now we can define the api implementations. export const makeServerside = ( clientside: Clientside): Serverside => ({ async sum(a, b) { await clientside.now() // remember, each side can call the other return a + b }, }) export const makeClientside = ( getServerside: () => Serverside): Clientside => ({ async now() { return Date.now() }, })
- on the serverside, we create a websocket server
// ws/server.js import {WebSocketServer} from "renraku/x/server.js" import {Clientside, makeServerside} from "./apis.js" const server = new WebSocketServer({ acceptConnection: async({remoteEndpoint, req, ip, headers}) => { const clientside = remote<Clientside>(remoteEndpoint) return { closed: () => {}, localEndpoint: endpoint(makeServerside(clientside)), } }, }) server.listen(8000)
- note that we have to import from
renraku/x/server.js
, because we keep all node imports separated to avoid making the browser upset
- note that we have to import from
- on the clientside, we create a websocket remote
// ws/client.js import {webSocketRemote, Api} from "renraku" import {Serverside, makeClientside} from "./apis.js" const {remote: serverside} = await webSocketRemote<Serverside>({ url: "http://localhost:8000", getLocalEndpoint: serverside => endpoint( makeClientside(() => serverside) ), onClosed: () => { console.log("web socket closed") }, }) const result = await serverside.now()
endpoint
— function to generate a json-rpc endpoint for a group of async functionsimport {endpoint} from "renraku" const myEndpoint = endpoint(myFunctions)
- the endpoint is an async function that accepts a json-rpc request, calls the appropriate function, and then returns the result in a json-rpc response
- basically, the endpoint's inputs and outputs can be serialized and sent over the network — this is the transport-agnostic aspect
remote
— function to generate a nested proxy tree of invokable functions- you need to provide the api type as a generic for typescript autocomplete to work on your remote
- when you invoke an async function on a remote, under the hood, it's actually calling the async endpoint function, which may operate remote or local logic
import {remote} from "renraku" const myRemote = remote<typeof myFunctions>(myEndpoint) // calls like this magically work await myRemote.now()
fns
— helper function to keeps you honest by ensuring your functions are async and return json-serializable dataimport {fns} from "renraku" const timingFns = fns({ async now() { return Date.now() }, })
- you can throw an
ExposedError
when you want the error message sent to the clientimport {ExposedError, fns} from "renraku" const timingApi = fns({ async now() { throw new ExposedError("not enough minerals") // ↑ // publicly visible message }, })
- any other kind of error will NOT send the message to the client
import {fns} from "renraku" const timingApi = fns({ async now() { throw new Error("insufficient vespene gas") // ↑ // secret message is hidden from remote clients }, })
- the intention here is security-by-default, because error messages could potentially include sensitive information
maxRequestBytes
prevents gigantic requests from dumping on you10_000_000
(10 megabytes) is the default
timeout
kills a request if it goes stale10_000
(10 seconds) is the default
- set these on your HttpServer
new HttpServer(() => endpoint(fns), { timeout: 10_000, maxRequestBytes: 10_000_000, })
- or set these on your WebSocketServer
new WebSocketServer({ timeout: 10_000, maxRequestBytes: 10_000_000, acceptConnection, })
- renraku will log everything by default
- make renraku silent like this:
import {loggers} from "renraku" loggers.onCall = () => {} loggers.onCallError = () => {} loggers.onError = () => {}
- you can prefix a label onto onCall and onCallError, useful for distinguishing clients in the logs
import {loggers, RandomUserEmojis, endpoint, remote} from "renraku" const emojis = new RandomUserEmojis() // provides random emojis like "🧔" const {onCall, onCallError} = loggers.label(emojis.pull()) // wherever you're setting up your remote/endpoints.. const myRemote = remote<MyFns>(remoteEndpoint, {onCall}) const myEndpoint = endpoint(signalingApi, {onCall, onCallError})
- renraku has HttpServer and WebSocketServer out of the box, but sometimes you need it to work over another medium, like postMessage, or carrier pigeons.
- you're in luck because it's really easy to setup your own transport medium
- so let's assume you have a group of async functions called
myFunctions
- first, let's do your "serverside":
import {endpoint} from "renraku" import {myFunctions} from "./my-functions.js" // create a renraku endpoint for your functions const myEndpoint = endpoint(myFunctions) // create your wacky carrier pigeon server const pigeons = new CarrierPigeonServer({ handleIncomingPigeon: async incoming => { // you parse your incoming string as json const request = JSON.parse(incoming) // execute the api call on your renraku endpoint const response = await myEndpoint(request) // you send back the json response as a string pigeons.send(JSON.stringify(response)) }, })
- second, let's do your "clientside":
import {remote} from "renraku" import type {myFunctions} from "./my-functions.js" // create your wacky carrier pigeon client const pigeons = new CarrierPigeonClient() // create a remote with the type of your async functions const myRemote = remote<typeof myFunctions>( // your carrier pigeon implementation needs only to // transmit the json request object, and return then json response object async request => await carrierPigeon.send(request) ) // usage await myRemote.math.sum(1, 2) // 3
json-rpc has two kinds of requests: "queries" expect a response, and "notifications" do not.
renraku supports both of these.
don't worry about this stuff if you're just making an http api, this is more for realtime applications like websockets or postmessage for squeezing out a tiny bit more efficiency.
import {remote, query, notify, settings} from "renraku"
const fns = remote(myEndpoint)
- use the
notify
symbol like this to send a notification requestawait fns.hello.world[notify]() // you'll get null, because notifications have no responses
- use the
query
symbol to launch a query request which will await a responseawait fns.hello.world[query]() // query is the default, so usually this is equivalent: await fns.hello.world()
// changing the default for this request
fns.hello.world[settings].notify = true
// now this is a notification
await fns.hello.world()
// unless we override and specify otherwise
await fns.hello.world[query]()
const fns = remote(endpoint, {notify: true})
// now all requests are assumed to be notifications
await fns.hello.world()
await fns.anything.goes()
- the
remote
function applies theRemote
type automaticallyconst fns = remote(endpoint) // ✅ happy types await serverside.update[notify](data)
- but you might have a function that accepts some remote functionality
async function whatever(serverside: Serverside) { // ❌ bad types await serverside.update[notify](data) }
- you might need to specify
Remote
to use the remote symbolsimport {Remote} from "renraku" async function whatever(serverside: Remote<Serverside>) { // ✅ happy types await serverside.update[notify](data) }
💖 free and open source just for you
🌟 gimme a star on github