@atlas/server#
Bun.serve wrapper with an Elixir Plug-inspired pipe system.
Exports#
Conn (conn/index.ts)#
Conn— immutable connection type carrying request/response statecreateConn(req, params?)— create Conn from a Requestassign(conn, data)— merge data into conn.assignsputHeader(conn, key, value)— add a response headerhalt(conn, status, body?)— stop pipeline, set statussetStatus(conn, status)— set response status
Pipe (pipe/index.ts)#
PipeFn— type:(conn: Conn) => Conn | Promise<Conn>pipe(fn)— identity wrapper for type inferencepipeline(...pipes)(handler)— compose pipes, short-circuits on halt
Router (router/index.ts)#
Route— type:{ method, pattern, handler }get(path, handler)— create GET routepost(path, handler)— create POST routeput(path, handler)— create PUT routepatch(path, handler)— create PATCH routedel(path, handler)— create DELETE routehead(path, handler)— create HEAD routeoptions(path, handler)— create OPTIONS routerouter(...routes)— create fetch handler from Route objectsserve(options)— start Bun.serve with routes, port, hostname, websocket
Typed routes (route/index.ts)#
route(method, path, schemas, handler)— Route with validated, typed inputgetR | postR | putR | patchR | delR— method-specific shortcutsValidator<T>—(input: unknown) => Tor{ parse(input): T }(Zod-compatible)RouteSchemas<P, B, Q, A>—{ params?, body?, query?, before?, assigns? }TypedConn<P, B, Q, A>—Connwith narrowedparams,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-typetext(conn, status, body)— plain text responseredirect(conn, location, status?)— redirect (default 302)stream(conn, status, readable)— streaming response
Parsers (parsers/index.ts)#
parseJson— pipe that parses JSON bodyparseForm— pipe that parses URL-encoded form bodyparseMultipart— pipe that parses multipart form data
Errors (errors/index.ts)#
HttpError— tagged error object:{ status, message, code?, details?, headers? }httpError(status, message, opts?)— build anHttpErrorisHttpError(value)— type guard; routers/onError use it to render the right statusbadRequest,unauthorized,forbidden,notFound,methodNotAllowed,conflict,gone,unprocessable,tooManyRequests,internal,serviceUnavailable— status-specific factories:notFound("user not found")→ throws as 404haltWith(conn, error)— short-circuit the pipeline with anHttpError(alternative tothrow)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 + startcreateAdapter(name, start)— create a named server adaptercompose(adapters)— start multiple adapters, returnsComposedServerwithstop()
WebSocket (ws/index.ts) — import from @atlas/server/ws#
WsConn<T>— wrapped websocket connection with auto-JSON sendchannel(name, handlers)— define a typed pub/sub channelcreateRooms()— room manager with join/leave/broadcast/membersws(config)— build websocket handler + rooms + upgrade helperwsAdapter— standalone WS adapter for use withcompose()
SSE (sse/index.ts) — import from @atlas/server/sse#
SseClient— client with id, send, closecreateSseChannel()— managed SSE channel with broadcast + pipeeventStream(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