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

nestbox-core

v0.2.8

Published

NestJS-style decorators, DI container and router for Fastify + TypeBox.

Readme

nestbox-core

⚠️ FIRST TESTING VERSION — DO NOT USE IN PRODUCTION YET. This is an early, experimental release. APIs may change without notice and it has not been hardened or battle-tested. Use it for experiments and feedback only.

NestJS-style decorators, a small dependency-injection container, and a router for Fastify — with first-class TypeBox schemas that drive validation, serialization, and static types from a single source.

  • 🎯 Decorator-based controllers (@Controller, @Get, @Post, …)
  • 💉 Constructor dependency injection (@Injectable) with circular-dep detection
  • 🧩 One TypeBox schema per route → AJV validation + fast-json-stringify serialization + inferred request types
  • 🪶 Tiny and dependency-light: Fastify and TypeBox are peer dependencies
  • ⚡ Modern TC39 decorators — no reflect-metadata, no experimentalDecorators

📦 Just want to start? Scaffold a ready-to-run project with the create-nestbox starter: npm create nestbox@latest my-app (the app name is required).

🤖 Building with an AI agent? Point it at AI.md — a terse, example-first usage guide written for coding agents.

Install

You bring your own Fastify and TypeBox (they're peer dependencies):

npm install nestbox-core fastify @sinclair/typebox

Requirements

  • Node with ESM ("type": "module") — nestbox-core is ESM-only.

  • TC39 stage-3 decorators. Do not enable experimentalDecorators and do not install reflect-metadata. A minimal tsconfig.json:

    {
      "compilerOptions": {
        "module": "NodeNext",
        "moduleResolution": "NodeNext",
        "target": "ES2022",
        "strict": true
        // no "experimentalDecorators", no "emitDecoratorMetadata"
      }
    }

Quick start

import Fastify from 'fastify';
import { Controller, Get, nestbox } from 'nestbox-core';

@Controller('hello')
class HelloController {
  @Get('/world')
  world() {
    return { ok: true };          // a returned value is serialized and sent as JSON
  }
}

const app = Fastify();

nestbox({ app, controllers: [HelloController] })
  .setup()                        // wires routes, awaits app.ready()
  .then(app => app.listen({ port: 3000 }));
curl localhost:3000/hello/world
# {"ok":true}

You always construct and configure your own Fastify instance, then hand it to nestbox(...). The framework stays out of your Fastify version, plugins, and server options.

Controllers & routes

@Controller(prefix) marks a class; the method decorators register routes. The final URL is prefix + path with slashes collapsed and any trailing slash stripped (except the root /).

import { Controller, Get, Post, Patch, Delete } from 'nestbox-core';

@Controller('users')
class UsersController {
  @Get('/')        list()   { /* GET    /users      */ }
  @Get('/:id')     one()    { /* GET    /users/:id   */ }
  @Post('/')       create() { /* POST   /users       */ }
  @Patch('/:id')   update() { /* PATCH  /users/:id   */ }
  @Delete('/:id')  remove() { /* DELETE /users/:id   */ }
}

A handler's return value is sent as JSON. To take over the response yourself (status, headers, cookies), inject the raw reply — see Request data.

Dependency injection

Services live in a container and are resolved once (singletons). Because nestbox-core uses TC39 decorators (no emitted type metadata), you declare each class's dependencies explicitly as a token array — its order and length must match the constructor.

import { Controller, Get, Injectable } from 'nestbox-core';

@Injectable([])                 // no dependencies
class Clock {
  now() { return new Date().toISOString(); }
}

@Injectable([Clock])            // one dependency → constructor(clock)
class GreetingService {
  constructor(private clock: Clock) {}
  greet(name: string) {
    return { hello: name, at: this.clock.now() };
  }
}

@Controller('greet')
@Injectable([GreetingService])  // controllers are resolved through DI too
class GreetController {
  constructor(private greetings: GreetingService) {}

  @Get('/:name')
  greet() {
    return this.greetings.greet('world');
  }
}

Always add @Injectable([...]) to any class that has constructor parameters — controllers included. The container throws if a class needs arguments but isn't @Injectable, if the token array is shorter than the constructor, or if it detects a circular dependency.

Singleton-only by design — no request scope. Every provider is resolved once and cached for the app's lifetime; there is no NestJS-style Scope.REQUEST. Per-request data travels as handler arguments instead: inject request() via @Params, or reach a service from a hook through request.server.nestbox.container. This is deliberate — it avoids the contagious performance cost of request-scoped DI.

Non-class providers

For values that aren't classes (a config object, a third-party client, a function-built singleton), create a token and bind a factory during the synchronous boot phase:

import { createToken, nestbox } from 'nestbox-core';

interface Config { dbUrl: string; }
const CONFIG = createToken<Config>('config');

@Injectable([CONFIG])
class Repo {
  constructor(private config: Config) {}
}

nestbox({ app, controllers: [/* … */] })
  .boot(c => c.bind(CONFIG, () => ({ dbUrl: process.env.DB_URL! })))
  .setup()
  .then(app => app.listen({ port: 3000 }));

Request data & validation

@Params(...) declares, by argument position, what each handler parameter receives. The key helper is schema(...): it registers a TypeBox schema as the route's native Fastify schema and injects the validated request at that position.

import { Type } from '@sinclair/typebox';
import {
  Controller, Post, Params, schema,
  RouteSchema, ValidatedRequest,
} from 'nestbox-core';

const createUserSchema = {
  body: Type.Object(
    { name: Type.String({ minLength: 1 }), age: Type.Integer({ minimum: 0 }) },
    { additionalProperties: false },
  ),
  response: {
    200: Type.Object(
      { id: Type.String(), name: Type.String() },
      { additionalProperties: false },
    ),
  },
} satisfies RouteSchema;

// Re-export the inferred request type as your DTO:
export type CreateUserRequest = ValidatedRequest<typeof createUserSchema>;

@Controller('users')
class UsersController {
  @Post('/')
  @Params(schema(createUserSchema))
  create(req: CreateUserRequest) {
    // req.body is fully typed and already validated (AJV) by the time we get here
    const { name } = req.body;
    // `age` and any other field absent from the 200 response schema is stripped
    // from the output by fast-json-stringify:
    return { id: '1', name, internalSecret: 'hidden' };
  }
}

A RouteSchema accepts these TypeBox parts: { body?, querystring?, params?, headers?, response? } (with response keyed by status code). Two things worth remembering:

  • Input: set additionalProperties: false to reject unexpected request fields — AJV neither strips nor rejects them otherwise.
  • Output: declaring a response schema strips undeclared fields on its own — fast-json-stringify only emits the declared properties, so additionalProperties: false is not needed there. Beware the inverse: with no response schema, the raw returned object is sent and any extra ("private") fields leak.
  • Never put additionalProperties: true on a response schema. The response side runs through fast-json-stringify, not AJV — there is nothing to "reject" it, so it is honored: the serializer then passes every undeclared field straight through. That quietly turns a stripping schema into a leaking one, which is worse than no schema at all because it looks like the fields are guarded. It is never what you want on output.
  • The schema's querystring key surfaces on the request as query (req.query), matching Fastify.

Other parameter helpers

Mix helpers freely; just keep the handler's argument order matching the @Params(...) order.

import { Params, request, reply, container, schema } from 'nestbox-core';

@Get('/raw')
@Params(request(), reply())
raw(req: FastifyRequest, res: FastifyReply) {
  res.code(202).send({ accepted: true });   // sent manually → don't also return
}
  • request() → the raw FastifyRequest
  • reply() → the raw FastifyReply (status, headers, cookies, manual send)
  • container() → the DI Container, to resolve a service inside a method
  • schema(routeSchema) → register the route schema and inject the validated request
  • custom(resolve) → inject whatever your own callback returns (see below)

Building your own helper — custom(...)

custom(resolve) is the escape hatch for app-specific injection. The resolver receives (request, reply, container) and returns the value to inject at that position — sync or async (an async resolver is awaited before the handler runs). The handler argument is typed from the resolver's return type. Wrap it once to get a reusable, named helper instead of repeating request() + boilerplate in every handler:

import { custom } from 'nestbox-core';

// Defined once in your app. Pulls the user off the request (set by an auth hook)
// and resolves a guard from DI:
const bouncer = () =>
  custom((req, _reply, container) => container.resolve(Bouncer).forUser(req.user));

@Controller('me')
class MeController {
  @Get('/')
  @Params(bouncer())
  me(guard: Bouncer) {        // `guard` is typed as Bouncer, inferred from the resolver
    return guard.whoami();
  }
}

Hooks (middleware, auth)

@Hooks({...}) attaches Fastify lifecycle hooks (onRequest, preHandler, …), each value being a single handler or an array. On a controller class the hooks apply to all of its routes; on a single method they apply to just that route.

setup() decorates the Fastify instance with a nestbox namespace exposing the container, so a hook can pull services out of DI the usual Fastify way:

import { Controller, Get, Hooks, Injectable, UnauthorizedError } from 'nestbox-core';
import type { onRequestHookHandler } from 'fastify';

@Injectable([])
class TokenService {
  isValid(token: string | undefined) { return token === 'secret'; }
}

// plain `function` (not an arrow) so Fastify binds `this` to the instance
const guard: onRequestHookHandler = function (req, _reply, done) {
  const tokens = this.nestbox.container.resolve(TokenService);

  if(!tokens.isValid(req.headers.authorization)){
    throw new UnauthorizedError('bad token');
  }

  done();
};

@Controller('secure')
@Hooks({ onRequest: guard })
class SecureController {
  @Get('/data')
  data() { return { ok: true }; }
}

Errors

Throw a typed HTTP error from anywhere in the call stack (a service, a handler) — each subclass hard-codes its status, so you never have to thread reply through your code:

import { NotFoundError, ConflictError, BadRequestError } from 'nestbox-core';

throw new NotFoundError('user not found');
throw new ConflictError('email already taken');
throw new BadRequestError('invalid payload', validationDetails); // carries an `errors` field

Available classes (with their status codes) are in HttpError & subclasses — BadRequestError (400), UnauthorizedError (401), ForbiddenError (403), NotFoundError (404), MethodNotAllowedError (405), ConflictError (409), UnprocessableEntityError (422), TooManyRequestsError (429), InternalServerError (500).

To turn thrown errors — and Fastify's own schema-validation failures — into clean responses, register the bundled error handler on your instance before passing it to nestbox:

import Fastify from 'fastify';
import { registerDefaultErrorHandler, nestbox } from 'nestbox-core';

const app = Fastify();
registerDefaultErrorHandler(app, {
  // validationErrorStatus: 422,                 // default 400
  // validationErrorsMapper: (errors) => errors, // reshape reported validation errors
});

nestbox({ app, controllers: [/* … */] }).setup().then(app => app.listen({ port: 3000 }));

API documentation (OpenAPI / Swagger)

nestbox-core stamps descriptive metadata onto the route schema but stays Swagger-agnostic — wiring an actual UI (e.g. @fastify/swagger) is your app's job.

import { ApiTag, ApiOperation, ApiSecurity, compileApiTags } from 'nestbox-core';

@ApiTag({ name: 'Users', description: 'User management' })
@ApiSecurity({ bearerAuth: [] })            // default security for every route
@Controller('users')
class UsersController {
  @ApiOperation({ summary: 'Create a user', operationId: 'createUser' })
  @Post('/')
  create() { /* … */ }

  @ApiSecurity()                            // no args → marks this route public
  @Get('/health')
  health() { return { ok: true }; }
}

// Feed your Swagger config the deduped tag list:
const tags = compileApiTags([UsersController]);
  • @ApiTag — class-level tag.
  • @ApiOperation — method-level summary / description / operationId.
  • @ApiSecurity(...requirements) — class default or method override; calling it with no arguments marks a route public.

Lifecycle

nestbox({ app, controllers })
  .boot(c => c.bind(/* … */))   // optional, synchronous: register providers before anything resolves
  .setup()                      // async: decorate, register routes, await app.ready()
  .then(app => app.listen({ port: 3000 }));
  • .boot(cb) runs cb(container) immediately so you can bind(...) non-class providers up front. Skip it entirely if you have none.
  • .setup() resolves to a ready-to-listen FastifyInstance.

License

MIT