@atlas/server#

Bun.serve wrapper with an Elixir Plug-inspired pipe system.

Exports#

Conn (conn/index.ts)#

  • Conn — immutable connection type carrying request/response state
  • createConn(req, params?) — create Conn from a Request
  • assign(conn, data) — merge data into conn.assigns
  • putHeader(conn, key, value) — add a response header
  • halt(conn, status, body?) — stop pipeline, set status
  • setStatus(conn, status) — set response status

Pipe (pipe/index.ts)#

  • PipeFn — type: (conn: Conn) => Conn | Promise<Conn>
  • pipe(fn) — identity wrapper for type inference
  • pipeline(...pipes)(handler) — compose pipes, short-circuits on halt

Router (router/index.ts)#

  • Route — type: { method, pattern, handler }
  • get(path, handler) — create GET route
  • post(path, handler) — create POST route
  • put(path, handler) — create PUT route
  • patch(path, handler) — create PATCH route
  • del(path, handler) — create DELETE route
  • head(path, handler) — create HEAD route
  • options(path, handler) — create OPTIONS route
  • router(...routes) — create fetch handler from Route objects
  • serve(options) — start Bun.serve with routes, port, hostname, websocket

Typed routes (route/index.ts)#

  • route(method, path, schemas, handler) — Route with validated, typed input
  • getR | postR | putR | patchR | delR — method-specific shortcuts
  • Validator<T>(input: unknown) => T or { parse(input): T } (Zod-compatible)
  • RouteSchemas<P, B, Q, A>{ params?, body?, query?, before?, assigns? }
  • TypedConn<P, B, Q, A>Conn with narrowed params, body, query, assigns
import { getR, postR, pipeline } from "@atlas/server"
import { requireAuth } from "@atlas/auth"

postR(
  "/users/:groupId",
  {
    params: z.object({ groupId: z.coerce.number() }),
    body: z.object({ email: z.string().email(), name: z.string().min(1) }),
    before: [requireAuth({ secret })],
    assigns: {} as { auth: { id: number } },
  },
  async (c) => {
    // c.params.groupId: number
    // c.body: { email: string; name: string }
    // c.assigns.auth.id: number
    return json(c, 201, await createUser(c.assigns.auth.id, c.params.groupId, c.body))
  },
)

Validation failures throw unprocessable("Invalid <where>") with code: "VALIDATION_FAILED" and details: { where, issues }. JSON body parsing happens automatically when a body schema is provided — no need to add parseJson to before.

Response (response/index.ts)#

  • json(conn, status, data) — JSON response, sets content-type
  • text(conn, status, body) — plain text response
  • redirect(conn, location, status?) — redirect (default 302)
  • stream(conn, status, readable) — streaming response

Parsers (parsers/index.ts)#

  • parseJson — pipe that parses JSON body
  • parseForm — pipe that parses URL-encoded form body
  • parseMultipart — pipe that parses multipart form data

Errors (errors/index.ts)#

  • HttpError — tagged error object: { status, message, code?, details?, headers? }
  • httpError(status, message, opts?) — build an HttpError
  • isHttpError(value) — type guard; routers/onError use it to render the right status
  • badRequest, unauthorized, forbidden, notFound, methodNotAllowed, conflict, gone, unprocessable, tooManyRequests, internal, serviceUnavailable — status-specific factories: notFound("user not found") → throws as 404
  • haltWith(conn, error) — short-circuit the pipeline with an HttpError (alternative to throw)
  • onError(handler) — register a custom error handler pipe for the router

Throw-style error handling#

import { notFound, conflict, get, json, pipe } from "@atlas/server"

get("/users/:id", pipe(async (c) => {
  const user = await db.one(from(users).where(q => q("id").equals(c.params.id)))
  if (!user) throw notFound("user not found")
  return json(c, 200, user)
}))

The router catches thrown HttpErrors and renders them as JSON with the right status, code, details, and any custom headers. Non-HttpErrors become 500s with stack traces logged server-side only.

Adapter (adapter/index.ts)#

  • ServerAdapter<TConfig> — generic adapter type with name + start
  • createAdapter(name, start) — create a named server adapter
  • compose(adapters) — start multiple adapters, returns ComposedServer with stop()

WebSocket (ws/index.ts) — import from @atlas/server/ws#

  • WsConn<T> — wrapped websocket connection with auto-JSON send
  • channel(name, handlers) — define a typed pub/sub channel
  • createRooms() — room manager with join/leave/broadcast/members
  • ws(config) — build websocket handler + rooms + upgrade helper
  • wsAdapter — standalone WS adapter for use with compose()

SSE (sse/index.ts) — import from @atlas/server/sse#

  • SseClient — client with id, send, close
  • createSseChannel() — managed SSE channel with broadcast + pipe
  • eventStream(conn, generator) — one-off SSE response helper

Usage#

import { pipe, pipeline, router, serve, json, assign, parseJson, get, post } from "@atlas/server"

const logger = pipe((c) => {
  console.log(`${c.method} ${c.path}`)
  return c
})

const authed = pipeline(logger, parseJson)

serve({
  port: 3000,
  routes: [
    get("/", pipe((c) => json(c, 200, { status: "ok" }))),
    get("/users/:id", authed(
      pipe((c) => json(c, 200, { id: c.params.id }))
    )),
    post("/users", authed(
      pipe((c) => json(c, 201, { created: true, body: c.body }))
    )),
  ],
})

Architecture#

  • All functions are pure and return new Conn (immutable)
  • Pipes compose via pipeline(), halt short-circuits
  • Route builders (get, post, put, etc.) create typed Route objects
  • router(...routes) matches requests against Route objects
  • No classes, fully functional
Type to search guides and package references.