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

sleepy-serv

v0.26.0

Published

A more opinionated web server for building REST-ful applications

Readme

sleepy-serv

A directory-driven web server designed for REST-ful applications

Important Notes

  • This package requires bun.sh instead of NodeJS to run.
  • This project requires bun v1.4 or higher

Getting Started

The fastest way to scaffold a new project is the CLI:

bunx sleepy-serv init
bun install
sleepy dev

Or manually:

// src/index.ts
import { createApp } from 'sleepy-serv'

createApp(3000)

Routes live in src/api/ and are discovered automatically by the Bun plugin at startup. There is no need to pass a directory path or import routes yourself.

To start the dev server with the plugin preloaded:

sleepy dev

The Plugin

sleepy-serv uses a Bun plugin (sleepy-serv/plugin) to scan the filesystem and generate static imports for every route at load time. This replaces the previous dynamic import() and fs.readdirSync() approach and enables compatibility with bun build --compile --bytecode.

The plugin is loaded automatically by the CLI commands (sleepy dev, sleepy prod, and sleepy build). If you prefer to run your entrypoint directly, preload the plugin yourself:

sleepy init  # creates a new project
sleepy dev   # runs the app (watch mode)
sleepy prod  # runs the app (NO watch mode)
sleepy build # for building a single-file bundle via `bun build` (recommended)

Return Value

createApp() calls Bun.serve() under the hood and returns an app object with these properties:

  • routes: a list of all routes defined by the file structure, useful for debugging.
  • server: the object returned from Bun.serve(). Prefer app.close() (see Shutting Down) over calling server.stop() directly.
  • ws: WebSocket commands for interacting with connected clients: query(fn), send(fn, event, body), broadcast(event, body), and drop(signal, fn).
  • close: an async function that shuts the app down. See Shutting Down.

Shutting Down

app.close() shuts the app down and releases everything it holds:

const app = createApp(3000)

await app.close()

It stops the server, tears down the Ctrl+D shutdown handler, and calls onClose. Pass close(true) to force-close active connections rather than waiting for in-flight requests to finish. That argument is handed straight to server.stop(), so it behaves exactly as it does in Bun.serve().

This matters most for tests that start and stop an app per case. Calling server.stop() on its own stops the HTTP listener but leaves the Ctrl+D shutdown handler attached to stdin, which holds an open handle and can keep the process from exiting. app.close() does not have that problem.

Adding Routes

Routes are made up as resources and methods. Resources are described by a directory path, and methods are described by files created inside of those directories. Resource segments can also represent dynamic routing params by starting the directory name with a colon (:).

Here's file structure example:

/src
  index.js # this is where we called `createApp()`
  /api
    /users
      get.js
      post.js
      /:userId
        get.js
        delete.js
        put.js

The file structure above create the following routes:

  • GET /users
  • POST /users
  • GET /users/:userId
  • DELETE /users/:userId
  • PUT /users/:userId

These methods are supported:

  • GET
  • HEAD
  • PATCH
  • POST
  • PUT
  • DELETE

Method Definition Files

The route's logic is implemented in the route definition files. These must export default either a function, or an array of functions for middleware purposes. The function signature takes a BunRequest object, and must return a BunResponse object.

Here's an example of a simple handler function:

export default function (req, res, next) {
  console.log('res:', res)
  console.log('next:', next) // null for route handlers

  return new Response('Hello world')
}

These functions take three parameters:

  • req: a BunRequest object
  • res: a results object
  • next: a function to call the next middleware in the chain (or null for the final handler)

The res parameter might seem familiar if coming from ExpressJS, but this parameter has a different purpose. The res parameter is the "result" value handed to the current middleware. Middleware do not mutate it. Instead, whatever a middleware passes into next(data) becomes the res value the next middleware receives. This way, the req object stays as a lean BunRequest, and each middleware decides exactly what the next one sees.

The next parameter is a function that advances the chain. Whatever you pass to it becomes the next middleware's res. Calling next() with no argument sets the next res to undefined, so to forward the current result unchanged, return next(res). For route handlers (the final function in the chain), next will be null.

These handlers can also be async functions and sleepy-serv will wait for them to finish before moving on.

Middleware

As mentioned earlier, method definition files can also export an array of functions:

export default [
  async (req, res, next) => {
    /* transform `res` into the parsed body for the next middleware */
    return next(await req.json())
  },
  (req, res) => { /* notice that the last function doesn't take `next` */
    console.log('JSON body:', res)

    return new Response('Hello world')
  },
]

This is useful if you want to break common logic up into reusable functions. The functions in the array are called in-order, and each receives three parameters: req, res, and next.

Middleware Chain Behavior

Each middleware function must do one of the following:

  • Call next(data): Continue to the next middleware by returning next(data), where data becomes that middleware's res. To forward the current result unchanged, return next(res)
  • Return a Response: End the chain (early) by returning a BunResponse object
  • Throw an error: Stop the chain and trigger error handling

The next parameter will be null for the final function in the chain (the route handler), indicating there are no more middleware functions to call.

Example with Explicit next() Calls

export default [
  (req, res, next) => {
    console.log('1st middleware')

    if (req.headers.get('authorization')) {
      return next() // continue to next middleware
    } else {
      return new Response('Unauthorized', { status: 401 }) // end chain early
    }
  },
  (req, res, next) => {
    console.log('2nd middleware')

    return next({ // continue to route handler
      ...res,
      user: {
        id: 123,
        name: 'John',
      },
    })
  },
  (req, res) => {
    console.log('Final, "route" handler')

    return new Response(`Hello ${res.user.name}`)
  },
]

Middleware Execution Order

Middleware functions are executed in the following order:

  1. App-level middleware (defined in createApp() options)
  2. Directory-level middleware (from meta.js files, from root to leaf)
  3. Route-level middleware (defined in the route file array)

Each middleware function receives the same req object. The res value, by contrast, is whatever the previous middleware passed into next(data), allowing data to be transformed or accumulated as it flows through the chain.

Breaking the Middleware Chain - Responses

You might want to break the middleware chain with a response early.

Here's an example:

export default [
  (req, res, next) => {
    if (req.params.userId === '123') {
      return new Response('Returned early')
    } else {
      return next() // continue to next middleware
    }
  },
  (req, res) => {
    return new Response('End of chain')
  },
]

In the case above, the 1st middleware function will return a Response object if the route param's userId is equal to a specific value. In those cases, the last function in the middleware chain will not execute for that request. If the condition is not met, next() is called to continue to the next middleware.

Breaking the Middleware Chain - Errors

It's also common to throw errors for things like request validation or when a desired resource is not found. sleepy-serv has defined custom Error types for every type of 4xx and 5xx error:

import { NotFoundError } from 'sleepy-serv'

export default async function (req, res) {
  const users = await sql`
SELECT * FROM Users
WHERE userId=${req.params.userId}
  `

  const foundUser = users[0]

  if (foundUser) {
    return new Response.json(foundUser)
  } else {
    throw new NotFoundError()
  }
}

If the user is found in the database, then the request will return with a successful response containing the user's data. If the user is not found, then the NotFoundError() is thrown which will automatically respond with a 404 NotFound error.

Throwing generic errors also works too:

export default async function (req, res) {
  throw new Error('A problem occurred')
}

sleepy-serv will automatically respond with a 500 InternalServerError for any error types that aren't part of the sleepy-serv package.

Metadata Modules

It's also possible for resource directories to contain a meta.js file. These files can export various things that have some sort semantic relationship to the part of the route that they're defined in.

Directory-Level Middleware

meta.js files can also export an array of middleware functions:

// meta.js

export const middleware = [
  (req, res, next) => {
    /* do middleware things */
    return next(res) // Continue to next middleware, forwarding `res`
  },
]

This middleware will be applied to all sibling and descendent method definition files within that directory.

For example:

/src
  index.js
  /api
    meta.js # metadata
    get.js
    /users
      meta.js # metadata
      get.js
      post.js
      /:userId
        meta.js # metadata
        get.js
        delete.js
        put.js

The middleware defined in /api/meta.js will be applied by the following routes:

  • GET /
  • GET /users
  • POST /users
  • GET /users/:userId
  • DELETE /users/:userId
  • PUT /users/:userId

The middleware defined in /api/users/meta.js will be applied the following routes:

  • GET /users
  • POST /users
  • GET /users/:userId
  • DELETE /users/:userId
  • PUT /users/:userId

The middleware defined in /api/users/:userId/meta.js will be applied the following routes:

  • GET /users/:userId
  • DELETE /users/:userId
  • PUT /users/:userId

Meta Files

At the time of this writing, meta.js only exports middleware functions, but it will eventually be able to provide additional configuration for the endpoint definitions within its scope.

Built-In Middleware

sleepy-serv also comes built-in with a few useful middleware functions that are commonly used.

parseJsonBody()

This function returns a middleware that parses the request's body as a JSON string and forwards the parsed value as the next middleware's res via next(body). The returned middleware throws a BadRequestError if parsing fails, and forwards res unchanged (next(res)) when there is no content-type. Additional body parsers can be written in the future to accommodate other body encoding schemes (such as XML or protobuf).

Example usage:

import { parseJsonBody } from 'sleepy-serv'

export default [
  parseJsonBody(),
  (req, res) => {
    console.log('Parsed JSON:', res)

    return new Response('JSON received')
  },
]

validateSchemas(schemas)

This function returns a middleware function that validates request data against provided schemas. The returned middleware function has the signature (req, res, next) and forwards res via next(res) after successful validation.

The schemas object can contain these optional properties:

  • headers: takes a string formatter schema to evaluate req.headers. Header names are case-insensitive, so schema keys are lowercased to match; validation errors report the lowercased name (e.g. headers.userid).
  • params: takes a string formatter schema to evaluate req.params
  • query: takes a string formatter schema to evaluate req.query
  • body: takes a JSON validation schema to evaluate res (the body value passed in via next)

Only the keys you pass are validated. Any key you omit is skipped entirely, and res is forwarded unchanged via next(res); there is no implicit default schema for a missing body.

Example usage:

import { parseJsonBody, validateSchemas } from 'sleepy-serv'

export default [
  parseJsonBody(),
  validateSchemas({
    params: {
      userId: { type: 'format', value: 'uuid' }
    },
    body: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        email: { type: 'string', format: 'email' }
      },
      required: ['name', 'email']
    }
  }),
  (req, res, next) => {
    // Validation passed, process the request
    return new Response('Data is valid')
  },
]

Here's an example of the schema in action:

PUT /contacts/123/addresses

{
  street1: '123 Main St.',
  street2: '#100',
  city: 'Las Vegas',
  state: 'NV',
  postalCode: '12345',
}

TODO: add JSON schema example.

Please take a look at the packages/server/src/middleware.test.js test file for a far a comprehensive set of examples on how to use this middleware.


The headers, params, and query (querystrings) can only be strings, so they're evaulated using a simplified string format schema instead of a full JSON schema.

The string formatter schema looks like this:

{
  type: 'format',
  value: 'email',
}

type

Can either set to 'format' or 'pattern'. The value of type determines how the value property is used.

value

This determines how to evaulate the string based on the type parameter. If type is set to 'format', then value can be set to one of the pre-defined formats that's provided by the JSON schema specification. If type is set to 'pattern', then value can be set to a regex pattern instead.

Custom formats can also be provided by using the setValidationFormats() during app initialization. Here's an example:

import { setValidationFormats } from 'sleepy-serv'

setValidationFormats({
  phone: /^\d{10}$/, /* 10-digit, numeric string */
  postalCode: /^\d{5}$/, /* 5-digit, numeric string */
})

Calling setValidationFormats() extends the possible values that can be passed to the value property when evaulating strings in either the string formatter schema, or the body's JSON schema.

createApp() Options

middleware

You can define app-level middleware that will be applied to all routes:

import { parseJsonBody } from 'sleepy-serv'

const app = createApp(3000, {
  middleware: [
    parseJsonBody(),
    (req, res, next) => {
      console.log(`${req.method} ${req.url}`)
      return next()
    },
  ],
})

App-level middleware is executed before any directory-level or route-level middleware.

When WebSocket support is enabled (ws: true or ws: { ... }), the reserved /ws handshake routes are folded into these same chains, so app-level middleware runs against them too. A catch-all validator (for example a validateSchemas that requires a JSON body or a specific header on every request) will therefore also run against the body-less /ws handshake requests and reject them. Scope such validators below the reserved paths rather than applying them app-wide.

hostname

The hostname can be customized like so:

createApp(3000, {
  hostname: 'test.sleepy-serv.com',
})

mountPath

This adds a prefix to all routes. For example:

createApp(3000, {
  mountPath: 'api/public',
})

With the directory structure

/src
  index.js # createApp()
  /api
    get.js
    /users
      get.js
      post.js

Yields these routes:

  • GET /api/public
  • GET /api/public/users
  • POST /api/public/users

onClose

When the app is started, the app can be shutdown gracefully by pressing Ctrl+D in the terminal. That handler is only wired up when stdin is a TTY, so it's skipped in CI, in test runners, and anywhere stdin is piped. The onClose hook will be called during that shutdown if it's defined, and it's also called by app.close(). onClose can also be async as well.

const app = createApp(3000, {
  onClose: () => console.info('closing down...'),
})

ws

WebSocket support is opt-in. Pass ws: true to enable with defaults, or pass an options object to customize behavior:

createApp(3000, { ws: true })

With custom tuning and lifecycle hooks:

const app = createApp(3000, {
  ws: {
    heartbeatInterval: 30_000,
    dropThreshold: 120_000,
    reclaimTtl: 300_000,
    ticketTtl: 10_000,
    onOpen: clientId => console.log('connected:', clientId),
    onClose: (clientId, signal) => console.log('closed:', clientId, signal),
  },
})

When ws is omitted (or false), no /ws endpoints are registered and the server does not accept WebSocket upgrades. Calling app.ws or req.ws methods when WebSocket support is disabled throws an error.

  • heartbeatInterval: how often the client should send heartbeats, in milliseconds. Sent to the client in the welcome message. Defaults to 30_000.
  • dropThreshold: how long the server waits without an inbound message before reaping the connection, in milliseconds. Defaults to 120_000.
  • reclaimTtl: how long an inactive (reaped/dropped) session stays reclaimable, in milliseconds. Defaults to 300_000.
  • ticketTtl: how long a minted upgrade ticket stays valid, in milliseconds. Defaults to 10_000.
  • onOpen(clientId): fires after a client's welcome message is sent. Wrapped in try/catch so a throwing hook does not break the connection.
  • onClose(clientId, signal): fires when a connection closes. signal is a CloseSignal value ({ code, reason }). Also wrapped in try/catch.

WebSocket Commands

The app.ws object exposes four methods for interacting with connected clients:

  • query(fn, filter?): return a filtered list of sessions. The filter function receives (session, index) where session is a SessionEntry ({ clientId, type, data }), and returns a boolean. The optional filter parameter controls which sessions are iterated: 'active' (default), 'inactive', or 'all'. To list all active sessions: app.ws.query(() => true). To include inactive sessions: app.ws.query(() => true, 'all').
  • send(event, body, fn): push a notification to clients matching a filter. The filter function receives (session, index) and returns a boolean. To target one client: app.ws.send('ping', body, s => s.clientId === targetId).
  • broadcast(event, body): push a notification to all connected clients.
  • drop(signal, fn): close connections matching a filter. signal is a CloseSignal ({ code, reason }) with code validated in the range [4000-4099]. The filter function receives (session, index) and returns a boolean. To drop one client: app.ws.drop({ code: 4000, reason: 'kicked' }, s => s.clientId === targetId).

The same commands are available inside endpoint handlers via req.ws:

export default function (req) {
  const targetId = req.query.targetId

  req.ws.send('ping', { from: 'handler' }, s => s.clientId === targetId)

  return Response.json({ ok: true })
}

This works from both HTTP and WebSocket transports.

Typed Connection Data

All WebSocket types accept an optional generic parameter <T> that constrains session.data in filter functions and query results:

type ConnectionData = {
  userId: string
  role: 'player' | 'spectator'
}

const app = createApp<ConnectionData>(3000, { ws: true })

// session.data is typed as ConnectionData
app.ws.send('event', body, session => {
  return session.data.userId !== excludedId
})

const players = app.ws.query(session => {
  return session.data.role === 'player'
})

The type parameter propagates through Request<T>, Middleware<T>, and Handler<T>, so typed handler and middleware functions also get typed session data through req.ws:

import type { Request, AsyncHandlerResult } from 'sleepy-serv'

type ConnectionData = { userId: string }

export default async function (
  req: Request<ConnectionData>,
): AsyncHandlerResult {
  req.ws.send('joined', {}, session => {
    return session.data.userId !== 'some-id'
  })

  return new Response('', { status: 204 })
}

Exports

sleepy-serv exports several runtime constants and types:

  • ServerCloseSignals: server close signals: Ok ({ code: 1000, reason: 'ok' }), Reaped ({ code: 4998, reason: 'reaped' }), Superseded ({ code: 4999, reason: 'superseded' })
  • CloseSignal: the type for close signals: { code: number, reason: string }
  • StatusCode: the full range of HTTP status codes (1xx through 5xx)
  • HttpMethod: HTTP verbs: Head, Get, Post, Put, Patch, Delete
  • SessionType: session state: Active ('active'), Inactive ('inactive')
  • SessionFilter: session filter: Active ('active'), Inactive ('inactive'), All ('all')
  • SessionEntry<T>: the shape returned by query() and received by filter callbacks: { clientId: string, type: SessionType, data: T }
  • FilterFn<T>: the filter callback type: (session: SessionEntry<T>, index: number) => boolean
  • SocketCommands<T>: the type for app.ws and req.ws
  • Error classes for every 4xx and 5xx status (e.g. NotFoundError, UnauthorizedError, InternalServerError)
  • Middleware helpers: parseJsonBody, validateSchemas, setValidationFormats

CLI

The sleepy CLI is installed with the package and provides three commands:

sleepy init

Scaffolds a new project in the current directory with package.json, sleepy.config.ts, tsconfig.json, src/index.ts, and src/api/get.ts. If package.json already exists, it merges the dev and build scripts without overwriting other fields. If src/ already exists, existing files are not overwritten.

sleepy dev

Starts the dev server in watch mode with the plugin preloaded:

sleepy dev

Equivalent to:

bun --watch --preload sleepy-serv/plugin src/index.ts

sleepy prod

Starts the production server with the plugin preloaded (no watch mode):

sleepy prod

Equivalent to:

bun --preload sleepy-serv/plugin src/index.ts

Use this when bun build --compile is not an option (e.g. dynamic imports, native dependencies like pg, or when a single-file executable is not desired).

sleepy build

Produces a production build using Bun.build() with the plugin:

sleepy build

Output goes to ./dist by default. Build options like compile, bytecode, and outdir are configured in sleepy.config.ts (see Configuration).

Configuration

Create a sleepy.config.ts (or .js) in the project root:

export default {
  app: {
    root: './src/api',
    entrypoint: './src/index.ts',
  },
  build: {
    outdir: './dist',
    compile: false,
    bytecode: false,
    plugins: [],
  },
}

All fields are optional and fall back to the defaults shown above.