@wess/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 "@wess/atlas/server"
import { requireAuth } from "@wess/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 body when the content-type essence isapplication/jsonor*/*+json(parameters ignored;text/plain; x=application/jsonis not JSON)parseForm— 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 "@wess/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 @wess/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(req, server, data?)/upgradeFrom(req, server)helpersWsConfig.upgradeData(req)— buildws.datafrom the upgrade request (cookies, headers, session);serve()andwsAdaptercall itWsConfig.onError(error, ws)— called when any handler throws or rejects; the socket is then closed with 1011 (default:console.error). A failing handler never crashes the process- Channels with a
joinhook only dispatchhandlefor sockets whose join resolvedtrue;leaverevokes it. Channels withoutjoindispatch to every socket wsAdapter— standalone WS adapter for use withcompose()
SSE (sse/index.ts) — import from @wess/atlas/server/sse#
SseClient—{ id, topic?, lastEventId?, send(event, data, id?), close() };lastEventIdis theLast-Event-IDheader from a reconnecting EventSourcecreateSseChannel({ heartbeat?, topic? })— managed SSE channel:clients(),broadcast(event, data, id?),publish(topic, event, data, id?),pipe(conn)eventStream(conn, generator, { heartbeat? })— one-off SSE response helper;generator(send)wheresend(event, data, id?)SseId = string | number;SseOptions,SseChannelOptionstypes- Event ids: pass
idtosend/broadcast/publishand the frame gets anid:line (no newlines allowed). Browsers replay it asLast-Event-IDon reconnect; readclient.lastEventIdto resume. - Heartbeat:
heartbeatis ms between: pingcomment lines (off by default); the timer is cleared on close, cancel, and slow-client drop. Use it to keep proxies from idling the stream out. - Targeting:
client.sendhits one client (ch.clients().find(...)). For groups, give the channeltopic: (conn) => conn.query.roomand callpublish("room1", ...); clients with other topics (or none) are skipped.broadcaststill reaches everyone.
Usage#
import { pipe, pipeline, router, serve, json, assign, parseJson, get, post } from "@wess/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
serve accepts development?: boolean (default false) and maxRequestBodySize?: number (default 1 MiB). Raise the body limit explicitly for upload endpoints. Invalid JSON/forms return 400. SSE queues are bounded to 256 KiB per client; slow consumers are disconnected and must reconnect. Generator failures terminate the stream.