Serverless

Bun

Serve an @hulla/api contract directly from Bun with a Web-standard request handler.

Use @hulla/api/fetch to serve an @hulla/api implementation from Bun’s native HTTP server. Bun owns the listener; the Fetch adapter owns contract matching, declared input and response handling, and the Request/Response boundary.

This guide assumes a Bun project with a contract and server implementation to expose. It finishes with a local GET /api/profiles/:id request and a JSON response.

Install the packages

The example uses Zod for its request and response schemas. No Bun-specific @hulla/api package is required.

Declare and implement one route

Keep the contract in code shared by any clients and the server implementation in server-only code:

TypeScript
// src/api/contract.ts
import { defineContract, response, route } from '@hulla/api'
import { z } from 'zod'

export const contract = defineContract({
  basePath: '/api',
  routes: {
    profile: route.get('/profiles/:id', {
      params: z.object({ id: z.string() }),
      responses: {
        200: response.json(
          z.object({
            id: z.string(),
            runtime: z.literal('bun'),
          })
        ),
      },
    }),
  },
})
TypeScript
// src/api/server.ts
import { defineServer } from '@hulla/api/server'
import { contract } from './contract'

export const implementation = defineServer(contract).implement({
  profile: ({ params, response }) =>
    response(200, {
      id: params.id,
      runtime: 'bun',
    }),
})

The contract owns the /api/profiles/:id path and validates id before the handler runs. The implementation returns the status and body declared by that route.

Mount the handler in Bun

Create the Bun entrypoint at src/index.ts. Bun.serve receives the mounted Fetch handler directly:

TypeScript
// src/index.ts
import { fetchAdapter } from '@hulla/api/fetch'
import { implementation } from './api/server'

const handler = fetchAdapter().mount(implementation)

const server = Bun.serve({
  hostname: '127.0.0.1',
  port: Number(Bun.env['PORT'] ?? 3000),
  fetch: handler,
})

console.log(`Listening on ${server.url}`)

Start the server from the project root:

Terminal
bun run src/index.ts

In another terminal, call the route through Bun’s HTTP listener:

Terminal
curl --include http://127.0.0.1:3000/api/profiles/user-1

The response proves both boundaries selected the route: Bun delivered the request to handler, and the adapter matched the contract’s /api base path and profile route.

text
HTTP/1.1 200 OK
content-type: application/json

{"id":"user-1","runtime":"bun"}

Keep Bun and the adapter responsibilities separate

Bun decides how the server listens and what happens before or after the Fetch handler. Configure its hostname, port, TLS, logging, authentication, and platform limits in the Bun application. fetchAdapter() does not add a default body limit; set its maxBodyBytes option when the application needs an adapter-owned byte budget, while still respecting any limits imposed by Bun or an upstream proxy.

The handler uses Web Request and Response values. The adapter matches the request’s full pathname, so a request to /profiles/user-1 does not match this contract even though Bun delivered it to the listener. If the contract declares a body or a stream, the Fetch adapter reads and writes those Web representations; Bun remains responsible for the underlying connection and listener lifecycle.

For adapter options and the client-side Fetch transport, read the Fetch transport guide. For request-scoped values and middleware, read server context.

Use Bun’s HTTP server documentation for listener options and timeout behavior. Those server settings can end a connection independently of the adapter, including a stream that stays idle longer than the configured server timeout.