seamlessrpc

1.0.0 • Public • Published

🌐 SeamlessRPC

An RPC library for quick development of seamless full-stack applications.

Powered by a Vite plugin and inspired by Telefunc, tRPC and other similar libraries.

Previously known as @makay/rpc.



✨ Features:

  • 🎉 End-to-end TypeScript
  • 🚫 Zero boilerplate
  • 📡 Optional server-sent events support for real-time subscriptions
  • 🪶 Extremely small client bundle size addition
  • 🔗 Directly import and call tailored server functions from client code
  • 📄 Co-locate client and server files (or don't 🤷)
  • 📦 Front-end and back-end framework agnostic
  • 📦 Validation library agnostic
  • 🚫 Low server overhead with no implicit run-time validations
  • 🧰 Includes utilities for async server state and results
  • 🪝 Use the composables/hooks pattern in server code
  • 🔌 Includes adapters for popular libraries like Hono, Vue and Zod

🔧 Installation and setup

  1. Install a single package:

    npm i seamlessrpc
    yarn add seamlessrpc
    pnpm add seamlessrpc
    bun add seamlessrpc

    Everything is included out-of-the-box!

  2. Set up the Vite plugin:

    // vite.config.ts
    import { rpc } from "seamlessrpc/vite"
    import { defineConfig } from "vite"
    
    export default defineConfig({
      plugins: [
        rpc({
          url: "http://localhost:3000/rpc",
          credentials: "include",
        }),
      ],
    })

    You can run both vite to start a dev server or vite build to build for production.

  3. Set up the RPC server (example using the included Hono adapter):

    // src/server.ts
    import { serve } from "@hono/node-server"
    import { Hono } from "hono"
    import { cors } from "hono/cors"
    import { createRpc } from "seamlessrpc/hono"
    
    const app = new Hono()
    
    app.use(
      cors({
        origin: "http://localhost:5173",
        credentials: true,
      }),
    )
    
    const rpc = await createRpc()
    
    app.post("/rpc/:id{.+}", (ctx) => {
      return rpc(ctx, ctx.req.param("id"))
    })
    
    serve(
      {
        fetch: app.fetch,
        port: 3000,
      },
      (info) => {
        console.log(`Server is running on http://localhost:${info.port}`)
      },
    )

    You can run the above file with npx tsx src/server.ts.

    You can also run npx tsx watch src/server.ts to auto-reload during development.

🚀 Usage

Create client and server files and seamlessly import server types and functions from client code with full TypeScript support!

// src/components/Todos.ts
import { createTodo, getTodos, type Todo } from "./Todos.server"

let todos: Todo[] = []

async function main() {
  todos = await getTodos()

  console.log(todos)

  const newTodo = await createTodo("New Todo")

  console.log(newTodo)
}

main()
// src/components/Todos.server.ts
export type Todo = {
  id: string
  text: string
}

const todos: Todo[] = []

export async function getTodos() {
  return todos
}

export async function createTodo(text: string) {
  // TODO validate text

  const todo = {
    id: crypto.randomUUID(),
    text,
  }

  todos.push(todo)

  return todo
}

Serve the above src/components/Todos.ts through Vite and you should see the array of todos printed to your browser console. Reload the page a bunch of times and you should see the array grow since the state is persisted in the server!

In a real scenario you would store your data in a database rather than in the server memory, of course. The snippets above are merely illustrative.

📝 Input validation

There is no implicit run-time validation of inputs in the server. In the example above, the function createTodo expects a single string argument. However, if your server is exposed publicly, bad actors or misconfigured clients might send something unexpected which can cause undefined behavior in you program.

Therefore, it is ⚠️ extremely important ⚠️ that you validate the inputs of all exposed function.

Here's a basic example using the included Zod adapter:

import { z, zv } from "seamlessrpc/zod"

const TextSchema = z.string().min(1).max(256)

type Text = z.output<typeof TextSchema>

export async function createTodo(text: Text) {
  zv(text, TextSchema)

  // `text` is now safe to use
}

You can use any validation library or even your own custom code to validate your inputs since SeamlessRPC is completely agnostic. Just make sure to throw an instance of the included ValidationError so that the server responds with a 400 Bad Request instead of the default 500 Internal Server Error.

import { ValidationError } from "seamlessrpc/server"

export async function createTodo(text: string) {
  if (typeof text !== "string" || text.length < 1 || text.length > 256) {
    throw new ValidationError("Invalid text")
  }

  // `text` is now safe to use
}

If you are worried about forgetting to validate your inputs within the function, you can write a separate function signature with the expected types and then use unknown in the actual function implementation. The downside is that you have to explicitly provide a return type rather than letting it be inferred.

export async function createTodo(text: string): Promise<string>

export async function createTodo(text: unknown) {
  if (typeof text !== "string" || text.length < 1 || text.length > 256) {
    throw new ValidationError("Invalid text")
  }

  // `text` is now safe to use
}

🚨 Errors

SeamlessRPC includes the following error classes:

  • RpcError: The base error class for all SeamlessRPC errors.
  • InvalidRequestBodyError: Thrown when the request body is invalid or cannot be parsed.
  • ValidationError: Thrown when the input validation fails.
  • UnauthorizedError: Thrown when the request is unauthorized.
  • ForbiddenError: Thrown when the request is forbidden.
  • ProcedureNotFoundError: Thrown when the requested procedure is not found.

You can throw any error in your functions:

// src/components/Todos.server.ts
import {
  ValidationError,
  UnauthorizedError,
  ForbiddenError,
} from "seamlessrpc/server"

export async function createTodo(text: string) {
  // if text is invalid
  throw new ValidationError("Invalid text")

  // if user is not authenticated
  throw new UnauthorizedError()

  // if user is not allowed to perform an action
  throw new ForbiddenError()

  // custom error
  throw new Error("Something went wrong")
}

The included Hono adapter maps errors to HTTP status codes in the following manner, by default:

  • RpcError -> 500 Internal Server Error
  • InvalidRequestBodyError -> 400 Bad Request
  • ValidationError -> 400 Bad Request
  • UnauthorizedError -> 401 Unauthorized
  • ForbiddenError -> 403 Forbidden
  • ProcedureNotFoundError -> 404 Not Found
  • Custom errors -> 500 Internal Server Error

However, it also allows you to provide a custom error handler to handle errors in a different way. This is covered in the Hono section below.

📦 Async server state

SeamlessRPC provides a way to store temporary server state tied to a request. The state is stored within the server process using AsyncLocalStorage.

The state can be accessed from any function in a way that resembles the composables/hooks pattern.

// src/components/Example.server.ts
import { defineState } from "seamlessrpc/server"

export type User = {
  id: number
  name: string
}

const { createState, replaceState, clearState, useState, useStateOrThrow } =
  defineState<User>()

export async function example() {
  // throws if state has already been created
  let user = createState({
    id: 1,
    name: "John Doe",
  })

  // creates or replaces existing state
  user = replaceState({
    id: 2,
    name: "Jane Doe",
  })

  // clears state if it exists
  clearState()

  // returns undefined if state has not been created
  const maybeUser = useState()

  // throws if state has not been created
  user = useStateOrThrow()
}

Take a look at sandbox/src/server/auth.ts and sandbox/src/components/OnlineChat.server.ts for a full example of using async server state to implement basic authentication.

👍 Results

Results allow you to send fully typed serializable success or failure values back to the client.

You can create results using the ok and err functions.

import { ok, err } from "seamlessrpc/result"

const successResult = ok("success")
// Ok<string> -> { ok: true, value: "success" }

const errorResult = err("failure")
// Err<string> -> { ok: false, error: "failure" }

For stronger typing you can use the okConst and errConst convenience functions.

import { okConst, errConst } from "seamlessrpc/result"

// same as `ok("success" as const)`
const constSuccessResult = okConst("success")
// Ok<"success"> -> { ok: true, value: "success" }

// same as `err("failure" as const)`
const constErrorResult = errConst("failure")
// Err<"failure"> -> { ok: false, error: "failure" }

Here's a full example:

import { ok, errConst } from "seamlessrpc/result"

export async function example() {
  if (someCondition) {
    return errConst("some_error")
  }

  return ok("success")
}

const result = await example()
// Err<"some_error"> | Ok<string>

if (result.ok) {
  // result: Ok<string>
  console.log(result.value) // "success"
} else {
  // result: Err<"some_error">
  console.error(result.error) // "some_error"
}

Take a look at lib/src/result.ts for all the type definitions, including some helper types not mentioned here.

📡 Subscriptions

Subscriptions allow clients to receive real-time updates from the server via server-sent events (SSE).

Client support needs to be explicitly enabled because it increases the client bundle size by a small amount.

// vite.config.ts
import { rpc } from "seamlessrpc/vite"

export default defineConfig({
  plugins: [
    rpc({
      sse: true,
    }),
  ],
})

Then you can just return a ReadableStream from your exposed server functions. These functions can still receive inputs and throw errors as usual. For convenience, SeamlessRPC provides a helper function eventStream that makes it easier to set up and clean up an event stream.

// src/components/OnlineChat.server.ts
import { EventEmitter } from "node:events"

import { eventStream } from "seamlessrpc/server"

export type Message = {
  id: string
  topic: string
  text: string
}

// example event source
const events = new EventEmitter<{
  MESSAGE_CREATED: [message: Message]
}>()

setInterval(() => {
  events.emit("MESSAGE_CREATED", {
    topic: "general",
    text: "Hello, world!",
  })
}, 1000)

export async function useMessageCreatedEvents(topic: string) {
  // TODO validate topic
  // TODO check user auth

  return eventStream<Message>(({ enqueue }) => {
    console.log(`User subscribed`)
    events.on("MESSAGE_CREATED", onMessage)

    function onMessage(message: Message) {
      if (message.topic === topic) {
        enqueue(message)
      }
    }

    // return a cleanup function
    return () => {
      console.log(`User unsubscribed`)
      events.off("MESSAGE_CREATED", onMessage)
    }
  })
}

In your client code you can just read the ReadableStream and handle the events as they come in. If you are using Vue, SeamlessRPC provides a useSubscription helper function; check the Vue section below.

Take a look at sandbox/src/components/OnlineChat.vue and sandbox/src/components/OnlineChat.server.ts for a more advanced example.

🔌 Adapters

Hono

The Hono adapter allows you to use SeamlessRPC with a Hono back-end.

// src/server.ts
import { serve } from "@hono/node-server"
import { Hono } from "hono"
import { createRpc } from "seamlessrpc/hono"

const app = new Hono()

const rpc = await createRpc()

app.post("/rpc/:id{.+}", (ctx) => {
  return rpc(ctx, ctx.req.param("id"))
})

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`)
  },
)

The createRpc function accepts an optional object with the following optional properties:

  • onRequest: an async function that is called before each RPC request
  • onError: an async function that is called when an error occurs during an RPC request
  • files: an object with the following optional properties:
    • rootDir: the root directory of the RPC files
    • include: file patterns to include
    • exclude: file patterns to exclude
// src/server.ts
import { createRpc } from "seamlessrpc/hono"
import { RpcError, getHttpStatusCode } from "seamlessrpc/server"

const rpc = await createRpc({
  async onRequest(ctx) {
    // do something before each request
    // like initializing async server state
  },

  async onError(ctx, error) {
    // do something when an error occurs

    // log the error
    console.error(error)

    // keep default error handling behavior
    if (error instanceof RpcError) {
      return ctx.json(error, getHttpStatusCode(error))
    } else {
      throw error
    }

    // or send custom response
    return ctx.json("Something went wrong", 500)
  },

  files: {
    rootDir: "src",
    include: ["./**/*.server.ts"],
    exclude: ["./**/*.server.test.ts"],
  },
})

Check the Errors section above for more information regarding the default error handling behavior.

Vue

SeamlessRPC includes a useSubscription helper function that makes it easier to handle subscriptions in Vue applications. The function takes an object with the following properties:

  • source: an async function that resolves with a ReadableStream
  • onData: a callback function that will be called whenever new data is received
  • onClose: an optional callback function that will be called when the subscription is closed
  • onError: an optional callback function that will be called if the subscription is closed with an error
// script setup (or wrapped in a composable)
import { useSubscription } from "seamlessrpc/vue"

import { useMessageCreatedEvents } from "./OnlineChat.server"

const { isSubscribed, isSubscribing, subscribe, unsubscribe } = useSubscription(
  {
    source: async () => useMessageCreatedEvents("general"), // topic: "general"
    onData(message) {
      console.log(message)
    },
    onClose() {
      console.log("closed")
    },
    onError(error) {
      console.error(error)
    },
  },
)

onMounted(() => {
  subscribe().catch(console.error)
})

onBeforeUnmount(() => {
  unsubscribe().catch(console.error)
})

Take a look at sandbox/src/components/OnlineChat.vue for a more advanced example.

Zod

The Zod adapter allows you to use Zod for input validation.

// src/components/Todos.server.ts
import { z, zv } from "seamlessrpc/zod"

const TextSchema = z.string().min(1).max(256)

type Text = z.output<typeof TextSchema>

export async function createTodo(text: Text) {
  zv(text, TextSchema)

  // `text` is now safe to use
}

Check the Input validation section above for more information.

🧑🏻‍💻 Contributing

Contributions, issues, suggestions, ideas and discussions are all welcome!

This is a very young library and a lot can still change.

📄 License

MPL-2.0

Package Sidebar

Install

npm i seamlessrpc

Weekly Downloads

7

Version

1.0.0

License

MPL-2.0

Unpacked Size

69.8 kB

Total Files

48

Last publish

Collaborators

  • makay