npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 typed Context wrapping the standard Request/Response Web 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 pronghorn

Requires 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 / verifyToken

Quick 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 entirely

Schema 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/:id

Example 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 handler

Example 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.