Better Call

repository·main·Indexed 21 days ago

https://github.com/better-auth/better-call

A lightweight TypeScript web framework for creating dual-purpose endpoints that function as both local functions and server-side routes. It features a type-safe RPC client, a high-performance router based on rou3, and automatic OpenAPI schema generation. Better Call supports web-standard compatible servers such as Bun, Node.js, Next.js, and SvelteKit, and integrates with standard-schema compatible validation libraries like zod.

Tokens
12K
Snippets
37
Records
49
Agent score
74%

What's inside better-call

  1. Overview of Better Call

    main
    Better Call is a lightweight web framework designed for creating endpoints that serve two purposes: they can be invoked directly as normal functions or mounted to a router to be served by any web-standard compatible server (such as Bun, Node.js, Next.js, or SvelteKit). It includes a typed RPC client to enable type-safe invocation of these endpoints from the client side. The framework is built for TypeScript and utilizes a high-performance router based on rou3.
  2. Create and use Endpoints

    main

    Endpoints are the fundamental building blocks of better-call. You can create an endpoint using createEndpoint, which takes a path, an options object (defining method, body/query schemas, etc.), and a handler function.

    Endpoints can be used in two ways:

    1. Directly as a function: Call the endpoint object directly with an options object. This is useful for internal server-side logic.
    2. Via a Router: Mount the endpoint to a router to serve it over HTTP via a web-standard compatible server (like Bun or Node.js).
    import { createEndpoint } from "better-call"
    import { z } from "zod"
    
    // 1. Define the endpoint
    export const createItem = createEndpoint("/item", {
        method: "POST",
        body: z.object({
            id: z.string()
        })
    }, async (ctx) => {
        return {
            item: {
                id: ctx.body.id
            }
        }
    })
    
    // 2. Call it directly as a function
    const item = await createItem({
        body: {
            id: "123"
        }
    })
  3. Understand the StandardSchemaV1 interface

    main

    Better Call utilizes the StandardSchemaV1 interface to support validation libraries that adhere to the Standard Schema specification. This allows the library to work seamlessly with various schema validation tools by providing a unified way to validate unknown input values and retrieve inferred types.

    A schema object must include a ~standard property containing the following:

    • version: Must be 1.
    • vendor: The name of the schema library (e.g., 'zod', 'valibot').
    • validate: A function that accepts unknown and returns a Result<Output> or a Promise<Result<Output>>.
    • types (optional): An object containing the input and output types for TypeScript inference.
    // Example of what a compatible schema object looks like
    const mySchema: StandardSchemaV1 = {
      "~standard": {
        version: 1,
        vendor: 'my-validator',
        validate: async (value) => {
          // validation logic
          return { value: value as Output };
        },
        types: {
          input: unknown,
          output: string
        }
      }
    };
  4. How router middleware works

    main

    Middleware in better-call can be applied globally or to specific paths via the routerMiddleware configuration in createRouter.

    When a request matches a path defined in routerMiddleware, the middleware is executed before the actual endpoint handler. If a middleware returns a Response object, that response is immediately returned to the client, bypassing the endpoint handler.

    Middleware receives a context object containing path, method, headers, params, request, body, query, and context (from routerContext). Note that when running as middleware, the asResponse flag in the context is set to false.

    const router = createRouter(endpoints, {
      routerMiddleware: [
        {
          path: '/admin',
          middleware: async (ctx) => {
            if (!ctx.headers.get('authorization')) {
              return new Response('Unauthorized', { status: 401 });
            }
          }
        }
      ]
    });
  5. Understand the InputContext type

    main

    The InputContext type represents the raw input provided to an endpoint or middleware. It is used to define the shape of the data that the framework expects to receive. It is composed of several inferred types based on the provided EndpointOptions or MiddlewareOptions:

    • body: The validated body content (inferred from StandardSchemaV1 or metadata.$Infer.body).
    • method: The HTTP method (e.g., GET, POST, or *).
    • query: The validated query parameters.
    • params: The URL path parameters (inferred from the path string).
    • request: The raw Request object (if requireRequest is true).
    • headers: The request headers (if requireHeaders is true).

    Additionally, it includes control flags for the framework:

    • asResponse: If true, methods like .json() will return a response-like object instead of raw data.
    • returnHeaders: Indicates if headers should be returned.
    • returnStatus: Indicates if the status should be returned.
    • use: An array of middleware to apply.
    • path: The current request path.
    • context: A user-provided object for sharing state.
    • asResponse: If true, methods like .json() will return a response-like object instead of raw data.
  6. Use Middleware to Extend Context

    main

    Middleware is created with createMiddleware. When an endpoint uses a middleware via the use option, any object returned by the middleware handler is attached to ctx.context in the endpoint handler.

    import { createMiddleware, createEndpoint } from "better-call";
    
    const authMiddleware = createMiddleware(async (ctx) => {
        return { user: { id: "1" } }
    })
    
    const endpoint = createEndpoint("/profile", {
        method: "GET",
        use: [authMiddleware],
    }, async (ctx) => {
       // Access the middleware return value via ctx.context
       const user = ctx.context.user
       return { user }
    })
  7. Restrict Allowed Media Types

    main

    You can restrict which MIME types are accepted for request bodies using allowedMediaTypes. This can be configured at the Router level (applying to all endpoints) or the Endpoint level (overriding the router). If a request uses a disallowed type, the server returns 415 Unsupported Media Type.

    // Router-level restriction
    const router = createRouter({ createItem }, {
        allowedMediaTypes: ["application/json"]
    })
    
    // Endpoint-level override
    const uploadFile = createEndpoint("/upload", {
        method: "POST",
        metadata: {
            allowedMediaTypes: ["multipart/form-data"]
        }
    }, async (ctx) => { ... })
  8. Handle Responses and Errors in Handlers

    main

    Handlers can return various types: a Response object, a plain JavaScript value (which is serialized), or the result of ctx.json().

    Status Codes

    Use ctx.setStatus(status) to change the success status code.

    Errors

    To return an error, you can:

    1. Throw ctx.error(codeOrStatus, data, headers)
    2. Throw a new APIError instance
    3. Throw a raw status code (e.g., throw 400)

    When mounted to a router, errors are converted to HTTP responses. When called as a function, they are thrown.

    import { APIError } from "better-call"
    
    const createItem = createEndpoint("/item", {
        method: "POST",
    }, async (ctx) => {
        // Using ctx.error helper
        if (ctx.body.id === "123") {
            throw ctx.error("BAD_REQUEST", { message: "Id is not allowed" })
        }
    
        // Using APIError class
        if (ctx.body.id === "456") {
            throw new APIError("BAD_REQUEST", { message: "Id is not allowed" })
        }
    
        // Using status code
        if (ctx.body.id === "789") {
            throw ctx.error(400, { message: "Error" })
        }
    
        // Setting custom status
        ctx.setStatus(201)
        return { success: true }
    })
  9. Use the RPC Client to call Endpoints

    main

    The createClient function from better-call/client allows you to call server-side endpoints from a client with full type safety. You should pass the typeof router as a generic to createClient.

    Endpoints are called using a path string (e.g., "@post/item" for a POST endpoint at /item).

    import type { router } from "./router"
    import { createClient } from "better-call/client"
    
    const client = createClient<typeof router>({
        baseURL: "http://localhost:3000"
    })
    
    // The result follows the pattern: { data: T, error: null | Error }
    const { data, error } = await client("@post/item", {
        body: {
            id: "123"
        }
    })
  10. Generate OpenAPI Documentation

    main

    Better Call automatically generates OpenAPI schemas. By default, it uses zod schemas for body and query to populate the documentation and exposes it at /api/reference using Scalar.

    You can configure the OpenAPI settings in the createRouter options:

    • disabled: Boolean to turn off generation.
    • path: The URL where the documentation is served.
    • scalar: Configuration for the Scalar UI (title, version, description, theme).
    const router = createRouter({
        createItem
    }, {
        openapi: {
            disabled: false,
            path: "/api/reference",
            scalar: {
                title: "My API",
                theme: "dark"
            }
        }
    })
  11. Configure Endpoint Paths and Parameters

    main

    Endpoints support direct paths, path parameters, and wildcards:

    • Direct path: /item
    • Path parameters: /item/:id (accessible via ctx.params.id)
    • Wildcards: /item/**:name (the name parameter captures the remaining path segments)
    // path with parameters
    const endpoint = createEndpoint("/item/:id", {
        method: "GET",
    }, async (ctx) => {
        return { item: { id: ctx.params.id } }
    })
    
    // path with wildcards
    const endpoint = createEndpoint("/item/**:name", {
        method: "GET",  
    }, async (ctx) => {
        // ctx.params.name contains the remaining path
    })