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

@quilla-be-kit/http

v0.5.0

Published

Framework-agnostic HTTP layer: decorators (@Controller/@Get/@Post/.../@AuthorizeScope/@ValidateRequest), framework-agnostic router, WebServer interface, and a Hono adapter sub-path.

Downloads

1,056

Readme

@quilla-be-kit/http

Framework-agnostic HTTP layer for a quilla-be-kit service:

  • Controller decorators@Controller, @Get / @Post / @Put / @Patch / @Delete + *Public variants, @AuthorizeScope, @ValidateRequest.
  • Router — walks decorated controller instances, composes prefixes, sorts routes by specificity, bridges to ComponentRegistry<HttpModuleMeta> from @quilla-be-kit/runtime, and (when executionContext is configured) installs a system-owned execution-context bootstrap so every handler can rely on provider.getContext().
  • Typed auth middleware stackAuthMiddlewareStack enforces phase ordering (tokenVerificationsessionLoad?) so consumers can't misorder security middlewares. Compose it directly from @quilla-be-kit/security's middleware factories.
  • Request / response contractsHttpRequest, HttpResponse, HttpMiddleware, AuthenticatedToken, HttpAttributes.
  • Validator contractRequestValidator interface; wire Zod / Joi / Valibot / ArkType with a ~5-line adapter.
  • Hono adapter@quilla-be-kit/http/adapter/hono sub-path ships a HonoServer that implements WebServer. hono is an optional peer dep.

Runtime deps: @quilla-be-kit/errors, @quilla-be-kit/execution-context, @quilla-be-kit/observability, @quilla-be-kit/runtime.

Install

# Core:
pnpm add @quilla-be-kit/http @quilla-be-kit/errors @quilla-be-kit/execution-context \
         @quilla-be-kit/observability @quilla-be-kit/runtime

# Plus Hono adapter:
pnpm add hono

Node 22+.

TypeScript configuration

Controllers rely on stage-3 decorators (not the legacy experimentalDecorators). Your tsconfig.json needs:

{
  "compilerOptions": {
    "target": "ES2022",                     // or higher
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
    // experimentalDecorators — must be false or omitted
    // emitDecoratorMetadata   — must be false or omitted
  }
}
  • TypeScript 5.0+ (5.2+ recommended).
  • experimentalDecorators must be false (or absent). If you have it on for legacy reasons, @Controller/@Get/etc. will compile under the old decorator protocol and route metadata won't register. TS 5.x defaults to stage-3 when this flag is absent.
  • target"ES2022". Stage-3 decorators compile on top of ES2022 class semantics.

Consumers do not need to polyfill Symbol.metadata themselves — the library installs a shared identity (Symbol.for('Symbol.metadata')) at module load. You also do not need emitDecoratorMetadata; that's a legacy-decorator flag and does nothing for stage-3.

Quick start

import { AsyncExecutionContextProvider } from '@quilla-be-kit/execution-context';
import {
  Controller,
  Get,
  Post,
  GetPublic,
  AuthorizeScope,
  ValidateRequest,
  Router,
  type HttpRequest,
  type HttpResponse,
  type RequestValidator,
} from '@quilla-be-kit/http';
import { HonoServer } from '@quilla-be-kit/http/adapter/hono';
import { Runtime, ShutdownManager, ComponentRegistry } from '@quilla-be-kit/runtime';
import {
  authenticatedSessionMiddleware,
  bearerTokenMiddleware,
} from '@quilla-be-kit/security';
import { serve } from '@hono/node-server';

@Controller('/users')
class UsersController {
  @GetPublic('/healthz')
  async health(_req: HttpRequest): Promise<HttpResponse> {
    return { httpCode: 200, payload: { ok: true } };
  }

  @Get('/:id')
  @AuthorizeScope('user:read')
  async show(req: HttpRequest): Promise<HttpResponse> {
    const id = req.getParams()['id'];
    return { httpCode: 200, payload: { id } };
  }

  @Post('/')
  @AuthorizeScope('user:write')
  @ValidateRequest(CreateUserRequestDto, ['body'])
  async create(req: HttpRequest): Promise<HttpResponse> {
    const input = req.getValidatedInput<CreateUserCommand>();
    // ... application logic
    return { httpCode: 201, payload: { id: 'new-id' } };
  }
}

const provider = new AsyncExecutionContextProvider();

const components = new ComponentRegistry<{
  readonly controllers?: readonly object[];
}>();

components.register({
  name: 'users',
  meta: { controllers: [new UsersController()] },
});

const router = new Router({
  modules: components.getAll(),
  executionContext: { provider },
  globalMiddlewares: [/* your custom globals (cors, rate-limit, request-logger, ...) */],
  authMiddlewares: {
    tokenVerification: bearerTokenMiddleware({ tokenService }),
    sessionLoad: authenticatedSessionMiddleware({
      sessionStore,
      executionContextProvider: provider,
    }),
  },
});

const server = new HonoServer({
  port: 3000,
  router,
  requestValidator: zodRequestValidator, // see below
  serve: (app, port) => {
    const handle = serve({ fetch: app.fetch, port });
    return {
      close: () =>
        new Promise<void>((resolve, reject) =>
          handle.close((err) => (err ? reject(err) : resolve())),
        ),
    };
  },
});

const shutdown = new ShutdownManager({ timeoutMs: 10_000 });
shutdown.addPhase({
  name: 'http',
  participants: [{ name: 'HonoServer', dispose: () => server.close() }],
});

const runtime = new Runtime({ shutdownManager: shutdown });
await runtime.run(async () => {
  await server.listen();
});

Decorators

@Controller(prefix, options?)

Class decorator. Every route on the class gets prefix prepended. The optional second argument carries a controller-level version default (see Versioning).

@Controller('/users')
class UsersController { ... }

@Controller('/users', { version: '/api/v1' })   // controller-wide version default
class UsersController { ... }

HTTP method decorators

@Get(path, options?)          @GetPublic(path, options?)
@Post(path, options?)         @PostPublic(path, options?)
@Put(path, options?)          @PutPublic(path, options?)
@Patch(path, options?)        @PatchPublic(path, options?)
@Delete(path, options?)       @DeletePublic(path, options?)

The *Public variants mark the route as public — auth middlewares are skipped for these routes. The non-public variants run every registered authMiddleware before the handler.

The optional options argument (RouteOptions) carries a per-route version override (see Versioning):

@Get('/:id', { version: '/api/v2' })

Versioning

A version segment can be declared at three levels and is inserted resource-first into the composed path — after the module prefix, before the controller — so each module stays a clean future service boundary:

[module prefix] + [effective version] + [registration prefix] + [@Controller prefix] + [@Route path]

The effective version for a route resolves by precedence:

route option  ??  @Controller version  ??  HttpModuleMeta.version  ??  ''
  • HttpModuleMeta.version? — module-wide default (see the registry bridge).
  • @Controller(prefix, { version }) — controller-level default.
  • @Get('/x', { version }) — per-route override (available on every method + *Public decorator).
@Controller('/auth', { version: '/api/v1' })   // default for the whole controller
class AuthController {
  @Get('/:id')                                 // → /iam/api/v1/auth/:id  (module prefix /iam)
  async show() {}

  @Get('/:id', { version: '/api/v2' })         // → /iam/api/v2/auth/:id  (route override)
  async showV2() {}
}

Version segments go through the same leading-slash / no-trailing-slash normalization as every other segment (/iam + api/v1/iam/api/v1, no double slash). They are static, so they only add specificity and never trigger a false duplicate; two routes that differ only by version resolve to distinct paths. Version is orthogonal to *Public / auth — it affects the path only. When no version is set anywhere, composed paths are byte-identical to a service that never adopted versioning.

@AuthorizeScope(scope, mode?)

Scope-based authorization. Reads an AuthenticatedToken from request.getAttribute(HttpAttributes.VERIFIED_TOKEN) and checks the token's scopes against the required scope(s).

@AuthorizeScope('user:read')              // default: 'any' — passes if token has user:read
@AuthorizeScope(['user:read', 'admin'])   // passes if token has any of these
@AuthorizeScope(['user:write', 'admin'], 'all')  // requires both

Throws ForbiddenError on missing token or mismatch. An auth middleware (from @quilla-be-kit/security or consumer code) must have populated the VERIFIED_TOKEN attribute.

@ValidateRequest(schema, sources)

Merges data from the configured sources ('body', 'params', 'query'), injects scopeId and userId from ExecutionContext.session only when the schema declares those keys and a session is active, validates against schema using the server's RequestValidator, and attaches the validated value to the request. Retrieve with request.getValidatedInput<T>().

Auth-injection requires two things:

  • A live session on the request's ExecutionContext (i.e. the route ran through auth middleware that established one — anonymous and system contexts get no injection).
  • The RequestValidator implements the optional describeSchema(schema) method (see RequestValidator adapter below). Without it, auth-injection is skipped entirely — a fail-safe default that keeps surprise fields out of schemas that didn't ask for them.
@Post('/')
@ValidateRequest(CreateUserRequestDto, ['body'])
async create(req: HttpRequest): Promise<HttpResponse> {
  const input = req.getValidatedInput<CreateUserCommand>();
  // input is typed as CreateUserCommand — consumer asserts the runtime shape
}

On validation failure, throws ValidationError with context.issues containing the validator's raw error array (e.g. Zod issues, Joi details). resolveHttpError surfaces this as a 400 response with body.error.details.issues.

Multipart / form-data

HttpRequest exposes two methods for multipart bodies:

getFile(name: string): File | null
getFormFields(): Record<string, string | readonly string[]>
  • getFile(name) — returns the File object for the named file field, or null if absent or if the request is not multipart.
  • getFormFields() — returns all non-file form fields as a flat record. Multi-value fields (e.g. checkboxes) are returned as readonly string[]; single-value fields as string. Returns an empty object when the request is not multipart.
@Post('/avatar')
async uploadAvatar(req: HttpRequest): Promise<HttpResponse> {
  const file = req.getFile('avatar');
  if (!file) return { httpCode: 400, error: { message: 'avatar field required' } };
  const fields = req.getFormFields();
  // fields: { caption: 'My photo', tags: ['travel', 'outdoors'] }
  const bytes = new Uint8Array(await file.arrayBuffer());
  await this.avatarStore.save(userId, bytes, file.type);
  return { httpCode: 204 };
}

These methods are only available on multipart requests. For JSON bodies use getBody() as normal; getFile / getFormFields return null / {} on non-multipart requests.

Binary and stream responses

HttpResponse is a union of three shapes:

type HttpResponse = HttpJsonResponse | HttpBinaryResponse | HttpStreamResponse;
  • HttpJsonResponse — the default. Carries payload / error / metadata and gets wrapped in the standard envelope.
  • HttpBinaryResponse — carries a data: Uint8Array. Adapter writes the bytes directly.
  • HttpStreamResponse — carries a stream: ReadableStream<Uint8Array>. Adapter pipes the stream straight to the response.

The three are mutually exclusive at the type level. A handler picks the variant in its return type, and the adapter discriminates by field presence — there is no kind tag to set.

@Get('/:id/avatar')
async avatar(req: HttpRequest): Promise<HttpBinaryResponse> {
  const bytes = await this.avatars.load(req.getParams()['id']);
  return {
    httpCode: 200,
    headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=3600' },
    data: bytes,
  };
}

@Get('/:id/export')
async export(req: HttpRequest): Promise<HttpStreamResponse> {
  const stream = this.reports.streamCsv(req.getParams()['id']);
  return {
    httpCode: 200,
    headers: {
      'content-type': 'text/csv',
      'content-disposition': 'attachment; filename="report.csv"',
    },
    stream,
  };
}

content-type lives in headers like every other header — the response shape does not invent a separate field for it.

The real tradeoff is that binary responses lose the envelope convention — no payload / metadata wrapper around the bytes, and middleware can't introspect stream contents post-hoc. That's intrinsic to streaming, not a flaw: logging, response shaping, and validators that read response bodies all become no-ops on the binary path. You're opting out of the standard JSON shape so the framework can hand bytes directly to the socket.

Error handling caveat: a handler that throws before producing the response still goes through resolveHttpError and emits a normal JSON error envelope. A handler that throws mid-stream — after the response status is already committed — aborts the connection; the client sees a truncated body, not a JSON error.

RequestValidator adapter

Zod — use the out-of-the-box helper

The toolkit ships a ready-made Zod 4 adapter under @quilla-be-kit/http/validator/zod. It implements both validate and the optional describeSchema — the latter unwraps ZodPipe (produced by .transform(...)) so schemas from @quilla-be-kit/persistence/query-schema interoperate without any extra wiring.

import { createZodRequestValidator } from '@quilla-be-kit/http/validator/zod';

const server = new HonoServer({
  requestValidator: createZodRequestValidator(),
  // ...
});

Accepts an extractIssues(error) hook if you want to reshape Zod's raw issue array before it lands in ValidationError.context.issues:

createZodRequestValidator({
  extractIssues: (err) => err.issues.map((i) => ({ path: i.path, message: i.message })),
});

zod is an optional peer dep of @quilla-be-kit/http — required only when importing from this sub-path.

Other validators — ~5 lines

If you use Joi, Valibot, ArkType, or anything else, implement RequestValidator directly:

// Joi
import type { Schema } from 'joi';

const joiRequestValidator: RequestValidator = {
  validate: (schema, input) => {
    const result = (schema as Schema).validate(input, { abortEarly: false });
    return result.error
      ? { success: false, error: result.error.details }
      : { success: true, data: result.value };
  },
  // Optional: implement describeSchema to enable conditional auth-injection
  // in @ValidateRequest. Return { keys } for schemas whose top-level keys
  // are enumerable, null otherwise.
};

Pass to new HonoServer({ requestValidator, ... }). The library handles conversion from the { success, error } tuple to a thrown ValidationError — consumers never construct quilla-be-kit errors directly.

Router

const router = new Router({
  controllers: [new UsersController()],   // plain controller instances
  // OR via modules from ComponentRegistry<HttpModuleMeta>:
  modules: registry.getAll(),

  // Optional — when provided, Router installs a system execution-context
  // bootstrap before any consumer middleware. Every route (public and
  // non-public) gets a baseline anonymous context with a correlation id
  // read from `correlationIdHeader` (default `'x-correlation-id'`) or a
  // generated UUID if absent.
  // **Required iff `authMiddlewares` is set** — Router throws at construction
  // otherwise. Skip it for pure-public services that never call
  // `request.getExecutionContext()`. The provider carries its own factory
  // (default `executionContextFactory`); pass a custom factory via
  // `new AsyncExecutionContextProvider({ factory })` if you've extended the
  // ExecutionContext shape.
  executionContext: {
    provider,
    correlationIdHeader: 'x-request-id', // optional, defaults to 'x-correlation-id'
  },

  globalMiddlewares: [...],               // custom — run on every route after system bootstrap
  authMiddlewares: { tokenVerification, sessionLoad? },  // typed stack — non-public routes only
});
  • Controllers can be registered as plain instances (no extra metadata) or wrapped in { controller, prefix?, middlewares? } for per-controller prefix + middlewares.
  • Routes are sorted by specificity (static segments > parametric > wildcard) so /users/healthz matches before /users/:id.
  • Path composition: [module prefix] + [effective version] + [registration prefix] + [@Controller prefix] + [@Route path], normalized to a single leading slash and no trailing slash. The effective version is resource-first and resolves route option ?? @Controller version ?? HttpModuleMeta.version ?? '' — see Versioning.
  • Duplicate routes (same method + path) throw at construction time — you catch double-registrations at startup, not under load.

Middleware chain order

On a non-public route:

system executionContext bootstrap  →  globalMiddlewares[]  →  tokenVerification  →  sessionLoad?  →  route middlewares  →  handler

On a *Public route, the entire authMiddlewares stack is skipped:

system executionContext bootstrap  →  globalMiddlewares[]  →  route middlewares  →  handler

The system bootstrap is Router-owned and not configurable from outside — this eliminates "I forgot to add executionContextMiddleware" as a failure mode for services that use auth or read ExecutionContext. When executionContext is omitted, the bootstrap step is skipped entirely; services that never read context pay no boilerplate. Router throws at construction if authMiddlewares is set without executionContext — the known-static dependency is caught at startup, not at the first authenticated request. The typed AuthMiddlewareStack prevents phase misordering at the type level; the array in globalMiddlewares stays open-ended because custom middleware ordering is consumer-owned.

Bridge to ComponentRegistry<HttpModuleMeta>

ComponentRegistry<HttpModuleMeta> is the shared spine between @quilla-be-kit/runtime and @quilla-be-kit/http:

import { ComponentRegistry } from '@quilla-be-kit/runtime';
import { type HttpModuleMeta } from '@quilla-be-kit/http';

const registry = new ComponentRegistry<HttpModuleMeta>({
  contracts: [IAM_CONTRACT, DM_CONTRACT],
});

registry
  .register({
    name: 'iam',
    meta: {
      prefix: '/iam',
      version: '/api/v1',                       // module-wide default; routes/controllers can override
      controllers: [usersController, authController],
      middlewares: [iamModuleMw],
    },
    dispose: () => iamModule.dispose(),
  })
  .register({
    name: 'dm',
    meta: {
      prefix: '/dm',
      version: '/api/v1',
      controllers: [documentsController],
    },
  });

// Router reads the registry directly:
const router = new Router({ modules: registry.getAll(), ... });

// Shutdown phase reads the same registry:
shutdown.addPhase(registry.toShutdownPhase('modules'));

One source of truth: adding a new module means one .register(...) call, and both the route table and the shutdown ordering pick it up automatically.

WebServer interface

export interface WebServer {
  bootstrap(): void | Promise<void>;
  listen(): Promise<void>;
  close(): Promise<void>;
}
  • bootstrap() — wires routes, middlewares, error handler onto the underlying framework. Idempotent.
  • listen() — starts accepting connections.
  • close() — stops accepting connections and awaits in-flight requests.

HonoServer implements WebServer. Future adapters (Express, Fastify) would ship as additional sub-paths implementing the same interface — const server: WebServer = new HonoServer(...) stays the shape your composition root depends on.

Hono adapter

Sub-path: @quilla-be-kit/http/adapter/hono. Ships HonoServer only. hono is an optional peer dep pinned to 4.x.x.

import { HonoServer, type HonoServeFn } from '@quilla-be-kit/http/adapter/hono';
import { serve } from '@hono/node-server';

const honoServe: HonoServeFn = (app, port) => {
  const handle = serve({ fetch: app.fetch, port });
  return {
    close: () =>
      new Promise<void>((resolve, reject) =>
        handle.close((err) => (err ? reject(err) : resolve())),
      ),
  };
};

const server = new HonoServer({
  port: 3000,
  router,                 // HonoServer reads the execution-context provider from Router
  requestValidator,       // optional — required only if any route uses @ValidateRequest
  logger,                 // optional — used for startup/shutdown/error logs
  serve: honoServe,
});

The serve callback is where you pick your Node runtime — @hono/node-server, Bun's native serve, Deno's native serve, a test stub, etc. Runtime-specific so the adapter stays portable.

Consumer never constructs HonoRequestAdapter or HonoMiddlewareAdapter directly — HonoServer wires them internally.

CORS

Pass cors: { origins: string[] } to enable CORS. HonoServer registers Hono's built-in cors() middleware before any route, so preflight and actual requests are both handled — no extra dependency required (hono/cors ships with Hono).

const server = new HonoServer({
  port: 3000,
  router,
  serve: honoServe,
  cors: {
    origins: ['https://app.example.com', 'http://localhost:5173'],
  },
});

Requests from an unlisted origin receive no CORS headers — the browser blocks them. Requests with no Origin header (server-to-server) are unaffected.

Defaults applied when cors is set:

| Header | Value | |---|---| | Access-Control-Allow-Methods | GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS | | Access-Control-Allow-Headers | Content-Type, Authorization, If-Match, ETag | | Access-Control-Allow-Credentials | true | | Access-Control-Max-Age | 86400 (24 h) |

If you need non-default values, omit cors and wire hono/cors yourself inside the serve callback, or raise an issue.

Other frameworks

If you need Express or Fastify: open an issue. Adapter sub-paths ship as library additions when they exist, not as consumer extension points.

Testing controllers

Since controllers are plain classes with decorators, you test them the way you'd test any class — construct an instance, pass a fake HttpRequest, assert the HttpResponse. No framework, no server, no adapter.

const controller = new UsersController();
const response = await controller.show(fakeRequest({ params: { id: '42' } }));
expect(response.httpCode).toBe(200);

For integration tests, use HonoServer with a serve callback that captures app.fetch — see this package's adapter tests for the pattern.