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

@neoma/features

v0.4.0

Published

Feature flagging for NestJS controllers — gate routes behind binary on/off flags

Readme

@neoma/features

Feature flag gating for NestJS applications

Gate routes and controllers behind feature flags with a single decorator. Disabled or missing flags return HTTP 404 as if the route does not exist (fail-closed).

Motivation

Feature flags let you ship code to production behind a toggle. Without a framework-level solution, every controller ends up with manual if (flag) checks that are easy to forget and hard to audit. @neoma/features moves gating into the framework layer so your controllers stay clean.

The Problem

Without this package:

@Controller("bank-statements")
export class BankStatementsController {
  @Post("upload")
  async upload(@Body() dto: UploadDto) {
    if (!this.config.get("FEATURE_UPLOAD_BANK_STATEMENT")) {
      throw new NotFoundException()
    }
    // actual logic...
  }
}

Every route needs its own guard logic. Miss one and a half-built feature leaks to production.

The Solution

With this package:

@Controller("bank-statements")
export class BankStatementsController {
  @Post("upload")
  @Feature("UPLOAD_BANK_STATEMENT")
  async upload(@Body() dto: UploadDto) {
    // Only reachable when UPLOAD_BANK_STATEMENT is true
  }
}

The @Feature decorator and a global guard handle gating automatically. No boilerplate, no forgotten checks.

Installation

npm install @neoma/features

Peer Dependencies

npm install @nestjs/common @nestjs/core reflect-metadata rxjs express

@neoma/features requires express as a peer dependency — the per-request resolver receives the express Request. Fastify is not supported at this signature.

Basic Usage

1. Import the Module

Static configuration:

import { Module } from "@nestjs/common"
import { FeaturesModule } from "@neoma/features"

@Module({
  imports: [
    FeaturesModule.forRoot({
      flags: {
        UPLOAD_BANK_STATEMENT: true,
        NEW_DASHBOARD: false,
      },
    }),
  ],
})
export class AppModule {}

Async configuration via DI:

import { Module } from "@nestjs/common"
import { ConfigModule, ConfigService } from "@nestjs/config"
import { FeaturesModule } from "@neoma/features"

@Module({
  imports: [
    FeaturesModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        flags: {
          UPLOAD_BANK_STATEMENT: config.get("FEATURE_UPLOAD") === "true",
          NEW_DASHBOARD: config.get("FEATURE_DASHBOARD") === "true",
        },
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}

The module registers globally by default so you only need to import it once in your root module.

2. Decorate Routes

import { Controller, Get, Post, Body } from "@nestjs/common"
import { Feature } from "@neoma/features"

@Controller("bank-statements")
export class BankStatementsController {
  @Post("upload")
  @Feature("UPLOAD_BANK_STATEMENT")
  async upload(@Body() dto: UploadDto) {
    // Only reachable when UPLOAD_BANK_STATEMENT is true
    // Returns 404 when the flag is false or missing
  }

  @Get()
  async list() {
    // No @Feature -- always accessible
  }
}

3. Gate an Entire Controller

Apply @Feature at the class level to gate all routes on a controller:

import { Controller, Get } from "@nestjs/common"
import { Feature } from "@neoma/features"

@Controller("dashboard")
@Feature("NEW_DASHBOARD")
export class DashboardController {
  @Get()
  index() {
    // Gated behind NEW_DASHBOARD
  }

  @Get("stats")
  @Feature("DASHBOARD_STATS")
  stats() {
    // Handler-level @Feature overrides the controller-level flag.
    // This route checks DASHBOARD_STATS, not NEW_DASHBOARD.
  }
}

When both the controller and a handler have @Feature, the handler-level flag takes priority (most specific wins).

4. Compute Flags Per Request

Static flags are the same for every request. For flags that vary per user, tenant, or request context, provide a resolve function. The guard invokes it on every gated request and unions the result with the static flags map.

FeaturesModule.forRoot({
  flags: { CHECKOUT_V2: true },
  resolve: (req) => req.user?.features ?? {},
})

Resolvers can be async and can pull from any service via forRootAsync:

FeaturesModule.forRootAsync({
  imports: [UsersModule],
  inject: [UserService],
  useFactory: (users: UserService) => ({
    flags: { CHECKOUT_V2: true },
    resolve: async (req) => users.featuresFor(req.user.id),
  }),
})

Union semantics. A request is admitted for @Feature(name) when:

flags[name] === true || (await resolve(req))[name] === true

Static wins. A resolver returning { name: false } does NOT override a static flags[name] === true. Use static flags for hard on/off; use the resolver to grant additional access per request.

When to use which. Reach for static flags when a feature is on or off for everyone (env-driven rollout, kill switch). Reach for resolve when availability depends on the request itself (user entitlements, tenant plans, A/B cohort).

5. Customising the Deny Response

By default, a denied request returns HTTP 404 via NotFoundException. For some routes a 404 is the wrong signal — you may want 403 Forbidden, 409 Conflict, or a custom payload that tells the caller why the route is unavailable. Supply an onDeny factory on the decorator to control what is thrown on the deny path:

import { Controller, ForbiddenException, Post } from "@nestjs/common"
import { Feature } from "@neoma/features"

@Controller("checkout")
export class CheckoutController {
  @Post()
  @Feature("CHECKOUT_V2", {
    onDeny: (req) =>
      new ForbiddenException({
        message: "Checkout disabled",
        requestId: req.headers["x-request-id"],
      }),
  })
  async checkout() {
    // Handle the request
  }
}

The factory receives the live express Request — the same one the resolver would see — so you can read headers, req.user, or anything else bound to the current request.

Admit path is untouched. onDeny is only invoked when the guard denies. A request admitted by either the static flags or the resolve function never calls onDeny.

Handler-level @Feature fully overrides class-level. When a handler re-declares @Feature without options, the class-level onDeny is discarded — the handler falls back to the default NotFoundException. There is no field-level inheritance; each @Feature stands on its own.

Whatever you return is thrown. The guard throws the value returned by onDeny as-is. Typically that's an HttpException subclass (ForbiddenException, UnauthorizedException, etc.) so Nest's default exception filter formats the response — but you can return any value as long as your exception pipeline can handle it. It's the consumer's responsibility.

6. Programmatic Checks Inside a Handler

@Feature gates a whole route. Some handlers need to always run but branch internally — for example, always accept a document upload, but only compute an AI summary when DOCUMENT_AI_SUMMARY is enabled for this request. Inject FeaturesService and call isEnabled(name):

import { Injectable } from "@nestjs/common"
import { FeaturesService } from "@neoma/features"

@Injectable()
export class DocumentsService {
  constructor(private readonly features: FeaturesService) {}

  async upload(file: Buffer) {
    const doc = await this.store(file)
    if (await this.features.isEnabled("DOCUMENT_AI_SUMMARY")) {
      doc.summary = await this.summarise(file)
    }
    return doc
  }
}

isEnabled evaluates the exact admit rule the guard evaluates for @Feature(name) on the same request (flags[name] === true || (await resolve(req))[name] === true). The resolver is invoked on every call — no memoisation. If caching matters, memoise inside your own resolver.

FeaturesService is Scope.REQUEST — inject it into request-scoped or per-call providers. Do not inject it into a singleton's construction path; DI will either fail or promote the singleton to request scope.

How It Works

  • A global APP_GUARD reads @Feature metadata from the handler and controller.
  • Handler-level metadata is checked first; if absent, controller-level metadata is used.
  • The flag name is looked up in the static flags record and, if provided, in the result of resolve(req). The request is admitted when either source yields === true.
  • If neither source yields true, the guard throws NotFoundException (HTTP 404).
  • Routes without any @Feature decorator are always accessible — the resolver is not invoked for ungated routes.

Fail-Closed Semantics

A flag that is not present in the flags record — and not returned as true by the resolver — is treated as disabled. You must explicitly set a flag to true (statically or via the resolver) to enable a route. This prevents accidentally exposing routes when a flag name is misspelled or forgotten.

API Reference

FeaturesModule

NestJS module that registers the feature guard globally.

| Method | Description | |--------|-------------| | FeaturesModule.forRoot(options) | Static configuration with a flags record | | FeaturesModule.forRootAsync(options) | Async configuration via factory, class, or existing provider |

FeaturesModuleOptions

interface FeaturesModuleOptions {
  /**
   * Static feature flag map. Missing flags are treated as disabled.
   * Optional — a consumer may configure only `resolve`.
   */
  flags?: Record<string, boolean>

  /**
   * Per-request resolver. Unioned with `flags` under strict `=== true`
   * semantics; a resolver returning `false` does NOT override static `true`.
   */
  resolve?: FeatureResolver
}

FeatureResolver

type FeatureResolver = (
  req: Request,
) => Record<string, boolean> | Promise<Record<string, boolean>>

The Request is the express request type (import type { Request } from "express"). Import FeatureResolver when you need to annotate a useFactory return or extract the resolver to its own function:

import type { FeatureResolver } from "@neoma/features"

const resolve: FeatureResolver = async (req) => {
  return req.user?.features ?? {}
}

@Feature(flag: string, options?: FeatureOptions)

Decorator that marks a controller or route handler as gated behind a feature flag. Can be applied to both classes and methods.

// On a method
@Feature("MY_FLAG")
@Get()
myRoute() {}

// On a controller
@Feature("MY_FLAG")
@Controller("my")
class MyController {}

// With a custom deny response
@Feature("MY_FLAG", {
  onDeny: (req) => new ForbiddenException("disabled"),
})
@Get()
myRoute() {}

FeatureOptions

interface FeatureOptions {
  /**
   * Factory invoked on the deny path to construct the value to throw. When
   * omitted, the guard throws `NotFoundException`.
   */
  onDeny?: FeatureOnDeny
}

FeatureOnDeny

type FeatureOnDeny = (req: Request) => unknown

Invoked only on deny. Receives the live express Request. Whatever it returns is thrown by the guard as-is — typically an HttpException subclass (e.g. ForbiddenException) so Nest's default exception filter formats the response. Any other value works too provided your exception pipeline handles it; that's the consumer's responsibility.

FeaturesService

Request-scoped service for programmatic flag checks from inside a handler or service. Mirrors the guard's admit rule exactly.

class FeaturesService {
  isEnabled(name: string): Promise<boolean>
}

Inject into request-scoped or per-call providers — not singleton construction paths.

License

MIT

Links

Part of the Neoma Ecosystem

This package is part of the Neoma ecosystem of Laravel-inspired NestJS packages: