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

@setu-ts/decorator-plugin

v0.11.0

Published

Optional NestJS-style decorators and reflection, without reflect-metadata

Readme

@setu-ts/decorator-plugin

Optional NestJS-style decorators as syntactic sugar over the kernel's programmatic API.

Decorators capture metadata in a plain MetadataStore — no reflect-metadata, no reflection polyfill. DecoratorPlugin reads that store at registration time and registers routes, services, and middleware with the kernel. Decorators are inert unless the plugin is registered.

The store itself is registered under CAPABILITIES.METADATA_STORE ('metadata-store').

Schema compatibility

@ValidateBody, @ValidateQuery, and @ValidateParams preserve application-created schemas and hand them to the validation/OpenAPI boundaries; this package does not inspect Zod internals. The supported Zod range is therefore the shared validation contract: >=4.6.6 <5. pnpm run check:compat exercises Zod 4 through the validation and OpenAPI owners.

Installation

import { Controller, DecoratorPlugin, Get } from '@setu-ts/decorator-plugin';

Usage

import {
  Body,
  Controller,
  DecoratorPlugin,
  Get,
  Param,
  Params,
  Post,
} from '@setu-ts/decorator-plugin';

@Controller('/users')
class UsersController {
  @Get('/:id')
  @Params(Param('id'))
  findOne(id: string) {
    return { id };
  }

  @Post('/')
  @Params(Body())
  create(body: unknown) {
    return body;
  }
}

app.register(DecoratorPlugin({ controllers: [UsersController] }));

Non-HTTP ingress

The same explicit class list registers queue processors, scheduled jobs, domain-event and broker subscriptions, WebSocket gateways, and CQRS handlers. Put those classes in ingress; registration happens during onInit, after the corresponding provider plugin has registered its capability. A declared handler with no provider fails startup with the class, method, and missing plugin named.

controllers and ingress are independent lists: a class listed in only one has the OTHER half of its composition ignored, and register() warns naming the ignored family and the option that would register it. Listing a class in both is the correct composition — both families register. A controller contributed by a @Module or by autoDiscover counts as registered for this check; @Module has no ingress list, so a module controller with ingress decorators still needs ingress.

import { DecoratorPlugin, Processor } from '@setu-ts/decorator-plugin';
import type { IJob } from '@setu-ts/common';

class BackgroundWork {
  @Processor('email')
  process(_job: IJob): void {}
}

app.register(DecoratorPlugin({ ingress: [BackgroundWork] }));

Register the provider plugins a class uses (QueuePlugin, SchedulerPlugin, EventsPlugin, MessagingPlugin, WebSocketPlugin, or CqrsPlugin) alongside the decorator plugin.

Validation is enforced

@ValidateBody(schema) (and @ValidateQuery / @ValidateParams) do not merely describe a route for OpenAPI. When a CAPABILITIES.VALIDATION provider is registered — ValidationPlugin, or any replacement — and enforceSchemas is not false, the capability's middleware is appended LAST in the route's chain (innermost, after guards), so an invalid request is answered 400 before the handler runs while guard 401/403 precedence is preserved. @Body(), @Query() and @Param() then hand the handler the VALIDATED value — transforms, defaults and coercions included — falling back to the raw source when no validated value exists. Without a validation provider the schemas stay description-only and the plugin logs one warning per affected route naming ValidationPlugin.

import {
  Body,
  Controller,
  DecoratorPlugin,
  Params,
  Post,
  ValidateBody,
} from '@setu-ts/decorator-plugin';
import { ValidationPlugin } from '@setu-ts/validation-plugin';
import { z } from 'zod';

const CreateUser = z.object({ email: z.string().email(), age: z.number().int().default(18) });

@Controller('/users')
class UsersController {
  @Post('/')
  @ValidateBody(CreateUser)
  @Params(Body())
  create(body: { email: string; age: number }) {
    return body; // the TRANSFORMED value: `age` defaulted to 18 by the schema
  }
}

app.register(ValidationPlugin());
app.register(DecoratorPlugin({ controllers: [UsersController] }));

Rendered views

@Render(Component) marks a route as rendered: the handler returns the component's PROPS bag and the framework answers with the component rendered to HTML (text/html; charset=utf-8), never JSON. The view is named by reference — a function the application already has — and the decorator type-checks that return against the component's props, so a wrong props bag is a compile error naming the mismatch.

import { Controller, Get, Render } from '@setu-ts/decorator-plugin';

// JSX escapes every interpolation, as does hono's `html` tag. A plain
// `(props) => \`<li>${user}</li>\`` template would NOT: escaping belongs to the
// rendering runtime, and a plain string component is returned unchanged.
const UserList = (props: { readonly users: readonly string[] }) => (
  <ul>
    {props.users.map((user) => (
      <li>{user}</li>
    ))}
  </ul>
);

@Controller('/pages')
class PagesController {
  @Render(UserList)
  @Get('/users')
  users() {
    return { users: ['ada', 'grace'] }; // the props bag — the framework answers HTML
  }
}

The engine is resolved from CAPABILITIES.VIEW once at register() — which is why the token joins this plugin's optionalDependencies (a real ordering edge, not priority luck). A rendered route with no provider FAILS at register(), naming the controller, the handler and both remedies (register ViewPlugin, or any other CAPABILITIES.VIEW provider); the check is per route, so an application with no @Render route needs no view plugin. A FIXED status or header alongside a rendered body is declared with @HttpCode / @ResponseHeader (see below), and a computed one goes through @Params(Ctx()); the return value IS the props bag, so it cannot also carry a status, which is why @Render grows no status argument of its own. Rendering itself — engines, escaping, the Suspense refusal — lives in @setu-ts/view-plugin; this package imports no rendering runtime.

Response shaping

A decorated handler returning a plain value is answered with ctx.response.json(result). Left undecorated that is the DEFAULT — 200, JSON, no headers — and the three decorators below change it, each writing to the response builder before the handler runs. @HttpCode(status), @ResponseHeader(name, value) (repeatable, for distinct names) and @Redirect(url, status?) let a route state something FIXED about its response in its declaration, instead of accepting a request context purely to say it.

import {
  Controller,
  Get,
  HttpCode,
  Post,
  Redirect,
  ResponseHeader,
} from '@setu-ts/decorator-plugin';

@Controller('/orders')
class OrderController {
  @HttpCode(201)
  @Post('/')
  create() {
    return { id: 'o-1' };
  }

  @ResponseHeader('Cache-Control', 'no-store')
  @Get('/latest')
  latest() {
    return { sku: 'a-1' };
  }

  @Redirect('/orders', 301)
  @Get('/v1')
  legacy() {
    return { moved: true };
  }
}

@Params(Ctx()) is neither replaced nor deprecated by these: it stays the way to compute a status or header PER REQUEST, and the only way to write a multi-valued header (ctx.response.appendHeader).

Three rules. A returned HandlerResult wins — the declared shaping is written to the response builder BEFORE the handler runs, so ctx.response.status(202).json(...) answers 202 even under @HttpCode(201), and the same ordering is why @Render composes with a declared status. @Redirect does not skip the handler — a decorator cannot decline to call the method, so the body still runs and a plain return is still serialised alongside Location. Every argument is refused at register(), never per request: @HttpCode takes an integer in [200, 599], @Redirect one in [300, 399] plus a non-blank target the runtime will carry as a Location header, a header pair must be one the runtime accepts, a name may not be declared twice, and one handler may not carry both @HttpCode and @Redirect. Each refusal names the controller, the method and the value — the alternative is a RangeError thrown inside the HTTP adapter after the pipeline has finished, or a TypeError while headers are written, answering 500 on every request.

@HttpCode(204) serves a bodiless response, which is what a DELETE handler wants.

@setu-ts/openapi-plugin derives an operation's success status from @HttpCode/@Redirect (on by default, deriveResponseStatus), so a 201 route is documented under 201 rather than the assumed 200. @ResponseHeader is deliberately not derived: an OpenAPI response-header entry needs a schema and a description the declaration does not carry.

What it exports

  • Routing — @Controller, @Version, @Get/@Post/@Put/@Patch/@Delete/@Head/@Options
  • Parameters — @Params(...), taking the sources Body, Query, Param, Header, Cookie, CurrentUser, Ctx, Service (a DI service from the request's scope), Custom
  • Modules — @Module({ controllers, providers, imports })
  • Non-HTTP ingress — @Processor, @Cron, @Every, @OnEvent, @Subscribe, @CommandHandler, @QueryHandler, @Gateway (+ @OnOpen/@OnMessage/@OnClose)
  • Injection — @Injectable, @Inject, @Optional
  • Security — @Roles, @Permissions (enforced — see below), @Public (unrestricted OpenAPI marking)
  • Authorization policies — @Policy, @Ability, @RequirePolicy (see Authorization policies below)
  • Pipeline — @UseGuards, @UseInterceptors, @UseFilters
  • Validation — @ValidateBody, @ValidateQuery, @ValidateParams
  • Idempotency — @Idempotent(options?), appended LAST in a route's middleware (after guards, declarative authorization and validation), so a refused or invalid request consumes no key; it requires a CAPABILITIES.IDEMPOTENCY provider — see @setu-ts/idempotency-plugin
  • Views — @Render(Component) (renders through a CAPABILITIES.VIEW provider — see @setu-ts/view-plugin)
  • Response shaping — @HttpCode, @ResponseHeader, @Redirect
  • OpenAPI — @ApiTags, @ApiOperation, @ApiResponse (@ApiOperation forwards operationId, summary and description into the route schema; OpenAPI uses a declared id verbatim and derives one from method and path when it is absent.)
  • Extension — createDecorator, Custom, registerParameterResolver
  • Discovery — discoverControllers

Options

| Option | Type | Default | Description | | ----------------- | --------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | controllers | Constructor[] | [] | Controller classes to register explicitly. | | ingress | Constructor[] | [] | Classes carrying non-HTTP ingress decorators (@Processor, @Cron, @OnEvent, …); registered during onInit. Independent of controllers — see Non-HTTP ingress. | | services | Constructor[] | [] | Service classes to register explicitly. | | modules | Constructor[] | [] | Root @Module classes to flatten depth-first; imported module providers register before controllers. | | autoDiscover | boolean | false | Scan controllersPath for decorated classes. | | controllersPath | string | — | Glob path used when autoDiscover is true. | | enforceSchemas | boolean | true | Append the validation capability's middleware for each present @ValidateXxx target. false keeps schemas description-only (OpenAPI) and silences the missing-provider warning. | | enforceRoles | boolean | true | Append enforcing authorization middleware for each route carrying @Roles/@Permissions. false keeps the metadata description-only and silences the missing-provider warning. | | policies | Constructor[] | [] | @Policy classes to construct (with constructor injection) and register with AuthPlugin's policy service before any controller. Refused without a CAPABILITIES.AUTHORIZATION_POLICIES provider. |

Discovery failures are logged as warnings and never crash the application.

Node and Bun: the Symbol.metadata carrier

Node 24 and Bun 1.4 do not define Symbol.metadata, which TC39 decorators store their metadata under. Importing @setu-ts/decorator-plugin installs it once, write-if-absent: Symbol.metadata ??= Symbol.for('Symbol.metadata'). This is a deliberate global mutation at module load; on Deno, which defines the symbol, it does nothing. Import the package before any module that defines a decorated class — an import { Controller } from '@setu-ts/decorator-plugin' in that module already guarantees it. A class evaluated first, or a bundle that dropped the import as side-effect-free, fails at definition with DecoratorMetadataUnavailableError, whose message names both remedies.

Security

@Roles and @Permissions are enforced by default: each decorated route's chain gets middleware that resolves CAPABILITIES.AUTHORIZATION per request and answers 401 without a principal and 403 when the check fails — the same statuses and bodies the equivalent @UseGuards(requireRole(...)) spelling produces. A route carrying both decorators requires any of the roles AND any of the permissions; method-level declarations override class-level ones.

When no authorization provider is registered, a decorated route fails closed: it answers 501 and the handler never runs — it is never served unguarded — and register() warns once per affected route naming both remedies (register a provider under CAPABILITIES.AUTHORIZATION, or set enforceRoles: false).

@Public contributes security: [] to the OpenAPI document only when the route has no enforced @Roles/@Permissions restriction. It does not exempt a route from a guard or from that enforcement; a restricted route keeps its derived OpenAPI security requirement.

Authorization policies

@Policy(name) marks a class as an authorization policy and @Ability() marks each ability method; a method named before is the policy's before hook. List the class in policies, and require an ability on a route with @RequirePolicy(policy, ability, target?) — the target is a value or an extractor called per request:

import type { IPrincipal } from '@setu-ts/common';
import {
  Ability,
  Controller,
  DecoratorPlugin,
  Patch,
  Policy,
  RequirePolicy,
} from '@setu-ts/decorator-plugin';

interface Post {
  readonly id: string;
  readonly authorId: string;
}

@Policy('post')
class PostPolicy {
  @Ability()
  update(principal: IPrincipal, post: Post | undefined): boolean {
    return post?.authorId === principal.id;
  }
}

@Controller('/posts')
class PostController {
  @Patch('/:id')
  @RequirePolicy(PostPolicy, 'update', (ctx) => ({ id: ctx.params.id ?? '', authorId: 'ann' }))
  update(): { readonly saved: boolean } {
    return { saved: true };
  }
}

export const decorators = DecoratorPlugin({
  policies: [PostPolicy],
  controllers: [PostController],
});

The policy service comes from AuthPlugin (@setu-ts/auth-plugin), which must be registered: register() fails, naming the route, when it is missing or when a @RequirePolicy names a policy or ability that is not registered. @RequirePolicy also accepts a definePolicy definition, may be repeated (every one must allow, top to bottom), and runs after guards and @Roles/@Permissions but before validation — so an extractor reading the body sees the unvalidated body. Refusals are 401 (anonymous) and 403 (signed in), the same bodies as AuthPlugin's requirePolicy guard; enforceRoles does not affect @RequirePolicy. See Authorization.

Scoped roles

@ScopedRoles(roles, scope?) (any-of) and @ScopedPermissions(permissions, scope?) (all-of) require a catalogue role or permission in the request's scope — the class form of AuthPlugin's requireScopedRole / requireScopedPermission. The scope defaults to the resolved request tenant; pass a ScopeSource from @setu-ts/common to read it elsewhere:

import { scopeFromParam } from '@setu-ts/common';
import {
  Controller,
  DecoratorPlugin,
  Get,
  Post,
  ScopedPermissions,
  ScopedRoles,
} from '@setu-ts/decorator-plugin';

@Controller('/invoices')
@ScopedRoles(['viewer'])
class InvoiceController {
  @Get('/')
  list(): string[] {
    return [];
  }

  // A method decorator overrides the class default.
  @Post('/:id/approve')
  @ScopedPermissions(['invoices:approve'])
  approve(): { readonly approved: boolean } {
    return { approved: true };
  }

  @Get('/orgs/:orgId')
  @ScopedRoles(['org-admin'], scopeFromParam('orgId', 'organisation'))
  forOrg(): string[] {
    return [];
  }
}

export const decorators = DecoratorPlugin({ controllers: [InvoiceController] });

They need AuthPlugin({ rbac, scopedRbac }): register() fails, naming the route, when no policy service is registered or a name is outside the catalogue (a role a tenant defines at runtime is checked through the permissions it bundles). They run after @Roles/@Permissions and @RequirePolicy, answer 401/403 with the same bodies as the guards, and are not governed by enforceRoles. See Scoped Roles.

Exports

| Export | Kind | | ---------------------------- | --------- | | Ability | function | | ApiOperation | function | | ApiResponse | function | | ApiTags | function | | Body | function | | clearParameterResolvers | function | | CommandHandler | function | | Controller | function | | Cookie | function | | createDecorator | function | | Cron | function | | Ctx | function | | CurrentUser | function | | Custom | function | | DecoratorPlugin | function | | discoverControllers | function | | Every | function | | Gateway | function | | getParameterResolver | function | | Header | function | | HttpCode | function | | Idempotent | function | | Inject | function | | Injectable | function | | Module | function | | OnEvent | function | | Optional | function | | Param | function | | Params | function | | parseCookies | function | | Permissions | function | | Policy | function | | Processor | function | | Public | function | | Query | function | | QueryHandler | function | | Redirect | function | | registerParameterResolver | function | | Render | function | | RequirePolicy | function | | resolveParameter | function | | resolveParameters | function | | ResponseHeader | function | | Roles | function | | ScopedPermissions | function | | ScopedRoles | function | | Service | function | | Subscribe | function | | UseFilters | function | | UseGuards | function | | UseIngressBehaviors | function | | UseInterceptors | function | | UsePipelineBehaviors | function | | ValidateBody | function | | ValidateParams | function | | ValidateQuery | function | | Version | function | | MetadataStore | class | | Delete | const | | Get | const | | Head | const | | metadataStore | const | | OnClose | const | | OnMessage | const | | OnOpen | const | | Options | const | | Patch | const | | Post | const | | Put | const | | AbilityOptions | interface | | ApiOperationConfig | interface | | ApiResponseConfig | interface | | DecoratorPluginOptions | interface | | DiscoveryOptions | interface | | DiscoveryResult | interface | | InjectableOptions | interface | | ModuleOptions | interface | | OptionalToken | interface | | ParameterMetadata | interface | | ParamSource | interface | | CustomParameterResolver | type | | HttpMethodDecorator | type | | InjectToken | type | | MiddlewareLike | type | | ModuleImporter | type | | ParameterType | type | | PolicyClassAbility | type | | PolicyClassTarget | type | | RenderDecorator | type | | SetuClassDecorator | type | | SetuClassOrMethodDecorator | type | | SetuMethodDecorator | type | | SourceValues | type |

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.