pronghorn
v0.1.4
Published
Pronghorn is a fast, lightweight, TypeScript-first backend framework built for Bun. Like its namesake, it's designed for speed and agility, with a clean API, zero-config setup, and a modular plugin system. Pronghorn brings together the simplicity of Expre
Maintainers
Readme
Pronghorn 🦌
Pronghorn is a fast, lightweight, TypeScript-first backend framework built for Bun. Like its namesake, it's designed for speed and agility, with a clean API, zero-config setup, and a modular plugin system. Pronghorn brings together the simplicity of Express and the performance-first mindset of Fastify, giving developers a streamlined way to build modern, scalable applications.
Built exclusively for Bun, no Node.js support, by design.
Why Pronghorn
Pronghorn avoids two failure modes seen in other Bun frameworks: forced file-based conventions, and unnecessary Node-era abstractions layered on top of what Bun's own Bun.serve already provides.
- Routes, middleware, and plugins are all defined programmatically, and registered via plain method calls (
app.get(...),app.use(...),app.register(...)). - Filesystem-convention discovery is available as an opt-in layer, fully composable with manual registration.
- It sits directly on top of
Bun.serve, with a typedContextwrapping the standardRequest/ResponseWeb APIs. - Middleware declares its own scope (
"global"or"route"), so you can control exactly where each one runs. - Pluggable, not batteries-included - it ships with generic building blocks (CORS, logging, JWT parsing, rate limiting, validation, EJS, static files, method override, favicon, virtual hosts) with zero opinions about your database or business logic.
- First-class WebSockets via
app.ws(path, handlers), dispatched through Bun's native WebSocket support, with built-in room-based pub-sub for broadcast/multicast patterns. - Per-route and global request timeouts, so a slow handler can never hang a connection indefinitely.
Installation
bun add pronghornRequires Bun >=1.3.0. Installing under Node.js fails intentionally during preinstall.
Optional peer dependencies - install only what you use:
bun add ejs # required for ejsPlugin
bun add jose # required for createJwtAuth / signToken / verifyTokenQuick Start
import { createApp, cors, logger, errorHandler } from 'pronghorn'
const app = createApp({ timeout: 10_000 }) // optional global default timeout in ms
app.use(cors)
app.use(logger)
app.use(errorHandler)
app.get('/', context => context.json({ message: 'Hello from Pronghorn 🦌' }))
const server = await app.listen(4000)
console.log(`Server running at http://localhost:${server.port}`)Core Concepts
Routes
Registered via app.get/post/put/patch/delete(path, handler, options?), with :param dynamic segments and optional per-route middlewares, schema validation, and timeout.
import { createApp, notFound } from 'pronghorn'
import { z } from 'zod'
const app = createApp()
// Static route
app.get('/health', context => context.json({ status: 'ok' }))
// Dynamic param
app.get('/users/:id', context => {
const { id } = context.params
return context.json({ id })
})
// Schema-validated POST
app.post('/users', context => {
const body = context.locals.body as { name: string; email: string }
return context.json({ created: body }, 201)
}, {
schema: {
body: z.object({
name: z.string(),
email: z.string().email()
})
}
})
// Per-route timeout override, throws a 408 HttpError if exceeded
app.get('/reports/export', async context => {
const csv = await generateSlowReport()
return new Response(csv, { headers: { 'Content-Type': 'text/csv' } })
}, { timeout: 30_000 })
// Not found fallback
app.get('/missing', () => { throw notFound() })Middleware
Shape: (context, next) => Promise<Response>. Each middleware exports a scope: "global" (runs on every request via app.use()) or "route" (opt-in per route via middlewares option).
import type { Middleware, MiddlewareScope } from 'pronghorn'
// Global middleware - runs on every request
export const scope: MiddlewareScope = 'global'
const requestId: Middleware = async (context, next) => {
context.locals.requestId = crypto.randomUUID()
return next()
}
export default requestId// Route-scoped middleware - must be explicitly opted in
import type { Middleware, MiddlewareScope } from 'pronghorn'
import { forbidden } from 'pronghorn'
export const scope: MiddlewareScope = 'route'
const adminOnly: Middleware = async (context, next) => {
const user = context.locals.user as { role?: string } | null
if (user?.role !== 'admin') throw forbidden()
return next()
}
export default adminOnly// Using route-scoped middleware on a route
import adminOnly from './middlewares/admin-only.middleware'
app.get('/admin/stats', context => context.json({ stats: [] }), {
middlewares: [adminOnly]
})Plugins
Plain functions that receive the shared AppContext and call app.decorate(name, value) to attach anything app-wide.
import type { PluginFn } from 'pronghorn'
export interface RedisPluginOptions {
url: string
}
export const redisPlugin: PluginFn = async (app, options) => {
const { url } = options as RedisPluginOptions
// e.g. const client = new Redis(url)
const client = { url, ping: () => 'PONG' } // placeholder
app.decorate('redis', client)
}// Registering and using the plugin
import { createApp } from 'pronghorn'
import { redisPlugin } from './plugins/redis.plugin'
const app = createApp()
await app.register(redisPlugin, { url: 'redis://localhost:6379' })
app.get('/ping', context => {
const redis = context.get<{ ping: () => string }>('redis')
return context.json({ pong: redis.ping() })
})Hooks
Fixed lifecycle points independent of the middleware chain. Use app.addHook(name, handler) for "onRequest", "preHandler", or "onResponse".
import { createApp } from 'pronghorn'
const app = createApp()
// Runs before global middleware
app.addHook('onRequest', context => {
console.log(`-> ${context.request.method} ${new URL(context.request.url).pathname}`)
})
// Runs after route matching, before the handler
app.addHook('preHandler', context => {
context.locals.startedAt = Date.now()
})
// Runs after the response is produced
app.addHook('onResponse', context => {
const ms = Date.now() - (context.locals.startedAt as number)
console.log(`← done in ${ms}ms`)
})WebSockets & Rooms
app.ws(path, handlers) gives each connection a WebSocketContext with built-in room support (join, leave, broadcast), backed entirely by Bun's native pub-sub (subscribe/unsubscribe/publish), no external message broker or in-memory registry required.
import { createApp } from 'pronghorn'
const app = createApp()
app.ws('/chat', {
open: ws => {
ws.join('lobby')
ws.send(JSON.stringify({ event: 'welcome', rooms: ws.rooms() }))
},
message: (ws, data) => {
const { text } = JSON.parse(data as string)
ws.broadcast('lobby', JSON.stringify({ event: 'message', text })) // fanout to everyone else in the room
},
close: ws => {
ws.leave('lobby')
}
})
// Broadcast from anywhere, even a plain HTTP route
app.post('/admin/announce', context => {
app.broadcastToRoom('lobby', JSON.stringify({ event: 'announcement', text: 'Server restarting soon' }))
return context.json({ sent: true })
})
await app.listen(4000)A single socket can join multiple rooms (e.g. lobby and user:42), enabling per-user, per-room, and broadcast-to-all patterns from the same connection.
Request Timeouts
Set a global default via createApp({ timeout }), or override per route via the timeout option. Exceeding it throws a 408 HttpError, caught automatically by errorHandler.
import { createApp, errorHandler } from 'pronghorn'
const app = createApp({ timeout: 15_000 }) // applies to every route unless overridden
app.use(errorHandler)
app.get('/instant', context => context.json({ ok: true })) // uses the 15s default
app.get('/heavy', heavyHandler, { timeout: 60_000 }) // needs more time
app.get('/unbounded', streamingHandler, { timeout: undefined }) // opt out entirelySchema Validation
Pass a Zod schema via a route's schema option. Pronghorn validates automatically and populates context.locals.body / context.locals.query, throwing a structured HttpError(400) on failure.
import { z } from 'zod'
app.get('/search', context => {
const { q, page } = context.locals.query as { q: string; page: number }
return context.json({ q, page })
}, {
schema: {
query: z.object({
q: z.string().min(1),
page: z.coerce.number().default(1)
})
}
})Error Handling
Throw HttpError or use the convenience helpers. The errorHandler middleware converts these into clean JSON responses.
import { createApp, errorHandler, HttpError, badRequest, unauthorized, forbidden, notFound } from 'pronghorn'
const app = createApp()
app.use(errorHandler)
app.get('/secret', context => {
throw unauthorized('You must be logged in')
})
app.get('/admin', context => {
throw forbidden()
})
app.get('/items/:id', context => {
const item = null // db lookup
if (!item) throw notFound('Item not found')
return context.json(item)
})
app.post('/data', context => {
throw badRequest('Missing required fields', { fields: ['name'] })
})
// Custom status code
app.get('/teapot', () => {
throw new HttpError(418, "I'm a teapot")
})Graceful Shutdown
import { createApp } from 'pronghorn'
const app = createApp()
app.onClose(async () => {
console.log('Closing DB connection...')
// await db.disconnect()
})
await app.listen(4000)Built-in Middleware
| Export | Scope | Description |
| ----------------------------- | ---------------- | ---------------------------------------------------------------- |
| cors | global | CORS headers + OPTIONS preflight handling |
| logger | global | Logs method, path, status, duration |
| errorHandler | global | Converts thrown HttpErrors into JSON responses |
| createJwtAuth({ secret }) | global (factory) | Decodes Bearer JWT into context.locals.user, never blocks |
| requireAuth | route | Rejects with 401 unless context.locals.user is set |
| rateLimit(limit, windowMs) | route (factory) | In-memory IP-keyed token-bucket limiter |
| validate(schema) | - | Applied automatically via a route's schema option |
| methodOverride() | global (factory) | Simulates PUT/PATCH/DELETE via _method field or header |
| favicon(path?) | global (factory) | Serves a cached /favicon.ico without a static-file lookup |
| vhost(hostname, handler) | global (factory) | Routes requests by Host header for multi-domain serving |
JWT Auth Example
import { createApp, createJwtAuth, requireAuth, signToken } from 'pronghorn'
const app = createApp()
const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret'
// Globally decode token if present (non-blocking)
app.use(createJwtAuth({ secret: JWT_SECRET }))
// Public: issue a token
app.post('/login', async context => {
// validate credentials here
const token = await signToken(JWT_SECRET, { userId: 1, role: 'admin' })
return context.json({ token })
})
// Protected: requireAuth blocks if no valid token was decoded
app.get('/me', context => {
return context.json({ user: context.locals.user })
}, {
middlewares: [requireAuth]
})Rate Limit Example
import { createApp, rateLimit } from 'pronghorn'
const app = createApp()
app.post('/register', context => context.json({ ok: true }), {
middlewares: [rateLimit(5, 60_000)] // 5 requests per minute
})Method Override Example
Lets a plain HTML <form method="POST"> simulate DELETE/PUT/PATCH without JavaScript.
import { createApp, methodOverride } from 'pronghorn'
const app = createApp()
app.use(methodOverride())
app.delete('/posts/:id', context => context.json({ deleted: context.params.id }))<form method="POST" action="/posts/42">
<input type="hidden" name="_method" value="DELETE" />
<button type="submit">Delete</button>
</form>Favicon Example
import { createApp, favicon } from 'pronghorn'
import { join } from 'node:path'
const app = createApp()
app.use(favicon(join(process.cwd(), 'public', 'favicon.ico')))Virtual Host Example
Serve multiple domains from a single Bun.serve instance.
import { createApp, vhost } from 'pronghorn'
const app = createApp()
app.use(vhost('api.example.com', apiHandler))
app.use(vhost('admin.example.com', adminHandler))Built-in Plugins
| Export | Decorates | Description |
| -------------- | ------------- | ------------------------------------------------------------------------- |
| ejsPlugin | render | Renders .ejs views with an optional shared layout |
| staticPlugin | serveStatic | Serves files (and optionally directory listings) with path-traversal protection |
| prismaPlugin | prisma | Attaches any DB client you pass in |
EJS Plugin Example
Requires bun add ejs.
import { createApp, ejsPlugin } from 'pronghorn'
const app = createApp()
await app.register(ejsPlugin, {
viewsDirectory: 'views', // default: 'views'
layout: 'layout' // optional: wraps all views in views/layout.ejs
})
app.get('/', async context => {
const render = context.get<(view: string, data?: Record<string, unknown>) => Promise<Response>>('render')
return render('home', { title: 'Home', user: 'Nolly' })
})views/layout.ejs:
<!DOCTYPE html>
<html>
<head><title><%= title %></title></head>
<body><%- body %></body>
</html>views/home.ejs:
<h1>Hello, <%= user %>!</h1>Static Plugin Example
import { createApp, staticPlugin } from 'pronghorn'
const app = createApp()
await app.register(staticPlugin, { directory: 'public', index: true })
app.get('/assets/*', async context => {
const serveStatic = context.get<(path: string) => Promise<Response | null>>('serveStatic')
const pathname = new URL(context.request.url).pathname.replace('/assets', '')
return (await serveStatic(pathname)) ?? Response.json({ error: 'Not found' }, { status: 404 })
})Prisma Plugin Example
import { createApp, prismaPlugin } from 'pronghorn'
import { PrismaClient } from '@prisma/client'
const app = createApp()
const prisma = new PrismaClient()
await app.register(prismaPlugin, { client: prisma })
app.get('/users', async context => {
const db = context.get<PrismaClient>('prisma')
const users = await db.user.findMany()
return context.json(users)
})Autoloading (Optional)
import { createApp, autoloadRoutes, autoloadMiddlewares, autoloadPlugins, autoloadHooks } from 'pronghorn'
import { join } from 'node:path'
const app = createApp()
await autoloadPlugins(app, join(__dirname, 'plugins'))
await autoloadMiddlewares(app, join(__dirname, 'middlewares'))
await autoloadHooks(app, join(__dirname, 'hooks'))
await autoloadRoutes(app, join(__dirname, 'routes'))
await app.listen(4000)Route files use a .method.ts suffix (.get.ts, .post.ts, etc.), folder structure maps to path segments, index files map to the parent path, and [param] folders/files become :param.
Example file structure:
routes/
index.get.ts -> GET /
users/
index.get.ts -> GET /users
index.post.ts -> POST /users
[id]/
index.get.ts -> GET /users/:id
index.delete.ts -> DELETE /users/:idExample route file (routes/users/index.post.ts):
import type { Handler } from 'pronghorn'
import { requireAuth } from 'pronghorn'
import { z } from 'zod'
export const schema = {
body: z.object({
name: z.string(),
email: z.string().email()
})
}
export const middlewares = [requireAuth]
const handler: Handler = async context => {
const body = context.locals.body as { name: string; email: string }
return context.json({ created: body }, 201)
}
export default handlerExample hook file (hooks/request-id.hook.ts):
import type { HookName, Hook } from 'pronghorn'
export const name: HookName = 'onRequest'
export const handler: Hook = context => {
context.locals.requestId = crypto.randomUUID()
}API Reference
createApp(options?: AppOptions): App - creates a new application instance. options.timeout sets a default per-request timeout in milliseconds for every route.
| Method | Description |
| ------------------------------------------------------ | -------------------------------------------------- |
| .use(middleware, scope?) | Registers global middleware |
| .register(plugin, options?) | Runs a plugin against the shared app context |
| .get/post/put/patch/delete(path, handler, options?) | Registers a route (options.timeout overrides the app default) |
| .ws(path, handlers) | Registers a room-aware WebSocket route |
| .broadcastToRoom(room, message) | Publishes a message to every socket in a room, from anywhere in the app |
| .addHook(name, handler) | Registers a lifecycle hook |
| .onClose(handler) | Registers shutdown cleanup logic |
| .listen(port) | Starts the Bun server |
| .close() | Stops the server manually |
Context exposes request, params, query, locals, get<T>(name), json(data, status?), and redirect(url, status?).
WebSocketContext (passed to ws() handlers) exposes raw (the underlying ServerWebSocket), join(room), leave(room), send(message), broadcast(room, message, excludeSelf?), and rooms().
The @pronghorn/* Ecosystem
Pronghorn's core stays deliberately small; everything else ships as focused, independently installable packages:
| Package | Purpose |
| --- | --- |
| @pronghorn/fawn | Lightweight templating engine, an alternative to EJS |
| @pronghorn/cookie | Signed, timing-safe cookie parsing and serialization |
| @pronghorn/session | Server-side session storage on top of @pronghorn/cookie |
| @pronghorn/bodies | JSON, urlencoded, and multipart/file-upload body parsing |
| @pronghorn/shield | Security response headers (CSP, HSTS, X-Frame-Options, etc.) |
| @pronghorn/csrf | Double-submit cookie CSRF protection |
| @pronghorn/compress | Streaming gzip/brotli/deflate response compression |
| @pronghorn/logger | Structured, correlation-ID-aware request logging |
| @pronghorn/openapi | Live OpenAPI 3.1 spec + Swagger UI generated from your Zod route schemas |
| @pronghorn/cli | Global scaffolding CLI (pronghorn new, generate, dev, build) |
Install only what your project needs, each package documents its own README with full usage examples.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.
