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
🌐 node and browser
🏛️ json-rpc 2.0
🔌 http and websockets
🚚 transport agnostic core
🛡️ auth helpers
🧪 testable


⛩️ RENRAKU — a simple idea

"an api should just be a bunch of async functions."

i had this idea in 2017, and since then i've been evolving the concept's implementation and typescript ergonomics.

this project is the result.


⛩️ RENRAKU — let's make a happy http api

  1. install renraku into your project
    npm i renraku
  2. example.ts — a bunch of async functions
    export const exampleFns = {
    
      async now() {
        return Date.now()
      },
    
      async sum(a: number, b: number) {
        return a + b
      },
    }
  3. server.ts — let's expose the functions on a node server
    import {exampleFns} from "./example.js"
    import {HttpServer, expose} from "renraku"
    
    new HttpServer(expose(() => exampleFns))
      .listen(8000)
  4. client.ts — finally, let's call the functions from a web browser
      // note, we import the *type* here
      //    ↓
    import type {exampleFns} from "./example.js"
    import {httpRemote} from "renraku"
    
    const example = httpRemote<typeof exampleFns>("http://localhost:8000/")
    
    // now you get a "natural" calling syntax,
    // feels like ordinary async functions:
    
    await example.now()
      // 1723701145176
    
    await example.sum(1, 2)
      // 3

RENRAKU — 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)

RENRAKU — http headers

  • remember when we exposed the functions on an http server?
    new HttpServer(expose(() => exampleFns))
      .listen(8000)
  • well, that expose function provides http headers
      //               http headers
      //                      ↓
    new HttpServer(expose(({headers}) => ({
      async sum(a: number, b: number) {
        console.log("content type", headers["content-type"])
        return a + b
      },
    }))).listen(8000)
  • if you're smart you can use the api helper to extract the functions to another file while keeping the types right
    import {api} from "renraku"
    
    export const exampleApi = api(({headers}) => ({
      async sum(a: number, b: number) {
        console.log("content type", headers["content-type"])
        return a + b
      },
    }))
    then you can expose it like this
    new HttpServer(expose(exampleApi))
      .listen(8000)
    and you can use that type in a remote like this
    const example = httpRemote<ReturnType<typeof exampleFns>>(
      "http://localhost:8000/"
    )

RENRAKUsecure and authorize

  • secure parts of your api by requiring 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,
        // (maybe get into bearer token crypto)
        if (auth !== "hello")
          throw new Error("failed fake authentication lol")
    
        // finally, return the functionality for this
        // authorized service
        return {
          async sum(a: number, b: number) {
            return a + b
          },
        }
      }),
    }
  • on the clientside, the auth param is required
    import type {exampleFns} from "./example.js"
    import {httpRemote, authorize} from "renraku"
    
    const example = httpRemote<typeof exampleFns>("http://localhost:8000/")
    
    // you can provide the 'auth' as the first parameter
    await example.math.sum("hello", 1, 2)
    
    // or authorize a whole group of functions
    const math = authorize(example.math, async() => "hello")
      // it's an async function so you could refresh
      // tokens or whatever
    
    // this call has been authorized
    await math.sum(1, 2)

RENRAKU — whimsical websockets

  • here our example websocket setup is more complex because we're setting up two apis that can communicate bidirectionally.
  • ws/apis.js — define your serverside and clientside apis
    import {api, Api} from "renraku"
    
    // first, we must declare our api types.
    // (otherwise, typescript gets thrown through a loop
    // 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 function makeServersideApi(clientside: Clientside) {
      return api<Serverside>(() => ({
        async sum(a, b) {
    
          // remember, each side can call the other
          await clientside.now()
    
          return a + b
        },
      }))
    }
    
    export function makeClientsideApi(getServerside: () => Serverside) {
      return api<Clientside>(() => ({
        async now() {
          return Date.now()
        },
      }))
    }
  • ws/server.js — on the serverside, we create a websocket server
    import {WebSocketServer} from "renraku"
    import {Clientside, makeServersideApi} from "./apis.js"
    
    const server = new WebSocketServer({
      acceptConnection: ({remoteEndpoint}) => {
        const clientside = remote<Api<Clientside>>(remoteEndpoint)
        return {
          closed: () => {},
          localEndpoint: expose(makeServersideApi(clientside)),
        }
      },
    })
    
    server.listen(8000)
  • ws/client.js — on the clientside, we create a websocket remote
    import {webSocketRemote, Api} from "renraku"
    import {Serverside, makeClientsideApi} from "./apis.js"
    
    const {socket, fns: serverside} = await webSocketRemote<Api<Serverside>>({
      url: "http://localhost:8000",
      getLocalEndpoint: serverside => expose(
        makeClientsideApi(() => serverside)
      ),
    })
    
    const result = await serverside.now()

RENRAKUnotify 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(endpoint)

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 — more about the core primitives

  • expose — generate a json-rpc endpoint for an api
    import {expose} from "renraku"
    
    const endpoint = expose(timingApi)
    • the endpoint is an async function that accepts a json-rpc request and calls the given api, 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
    • you can make your own async function of type Endpoint, that sends requests across the wire to a server which feeds that request into its own exposed api endpoint
  • remote — 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 timing = remote<typeof timingApi>(endpoint)
    
    // calls like this magically work
    await timing.now()

helper types

  • fns — keeps you honest by ensuring your functions are async
    import {fns} from "renraku"
    
    const timingApi = fns({
      async now() {
        return Date.now()
      },
    })
  • api — requires you to conform to the type that expose expects
    import {api} from "renraku"
    
    const timingApi = api(({headers}) => ({
      async now() {
        return Date.now()
      },
    }))

RENRAKU — error handling

  • you can throw an ExposedError in your async functions when you want the remote to see the error message:
    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 potentialy include sensitive information

RENRAKU — logging

  • renraku is silent by default
  • on the server, you can use various callbacks to do your own logging
    import {exampleFns} from "./example.js"
    import {HttpServer, expose} from "renraku"
    
    const endpoint = expose(() => exampleFns, {
    
      // log when an error happens during an api invocation
      onError: (error, id, method) =>
        console.error(`!! ${id} ${method}()`, error),
    
      // log when an api invocation completes
      onInvocation: (request, response) =>
        console.log(`invocation: `, request, response),
    })
    
    const server = new HttpServer(endpoint, {
    
      // log when an error happens while processing a request
      onError: error =>
        console.error("bad request", error),
    })
    
    server.listen(8000)

RENRAKU means contact

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