Skip to content

chase-moskal/renraku

Repository files navigation

連絡
R·E·N·R·A·K·U

🎨 make beautiful typescript apis.

📦 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


a simple idea

"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...


⛩️ RENRAKU — it's really this easy

  1. install renraku into your project
    npm i renraku
  2. 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
      },
    }
  3. 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)
  4. 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

arbitrary nesting is cool

  • 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)

http headers etc

  • 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)

RENRAKU — auth with secure and authorize

  • use the secure function to section off parts of your api that require auth
    import {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)
  • use the authorize function on the clientside to provide the auth param upfront
    import {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.

RENRAKU — whimsical websockets

  • 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
  • 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()

RENRAKU — more about the core primitives

  • endpoint — function to generate a json-rpc endpoint for a group of async functions
    import {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 data
    import {fns} from "renraku"
    
    const timingFns = fns({
      async now() {
        return Date.now()
      },
    })

RENRAKU — simple error handling

  • you can throw an ExposedError when you want the error message sent to the client
    import {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

RENRAKU — request limits

  • maxRequestBytes prevents gigantic requests from dumping on you
    • 10_000_000 (10 megabytes) is the default
  • timeout kills a request if it goes stale
    • 10_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 — logging

  • 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 — carrier pigeons, as custom transport medium

  • 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

RENRAKU — optimizations with notify and query

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.

let's start with a remote

import {remote, query, notify, settings} from "renraku"

const fns = remote(myEndpoint)

use symbols to specify request type

  • use the notify symbol like this to send a notification request
    await 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 response
    await fns.hello.world[query]()
    
    // query is the default, so usually this is equivalent:
    await fns.hello.world()

use the settings symbol to set-and-forget

// 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]()

you can even make your whole remote default to notify

const fns = remote(endpoint, {notify: true})

// now all requests are assumed to be notifications
await fns.hello.world()
await fns.anything.goes()

you can use the Remote type when you need these symbols

  • the remote function applies the Remote type automatically
    const 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 symbols
    import {Remote} from "renraku"
    
    async function whatever(serverside: Remote<Serverside>) {
    
      // ✅ happy types
      await serverside.update[notify](data)
    }

RENRAKU means contact

💖 free and open source just for you
🌟 gimme a star on github