@setu-ts/rest-starter

Opinionated plugin composition for building REST APIs with Setu-TS.

Provides a pre-configured set of plugins for building production-ready REST applications, including configuration, logging, validation, security, health checks, metrics, OpenAPI documentation, decorators, database support, and authentication.

Installation

npm install @setu-ts/rest-starter
# Deno: deno add npm:@setu-ts/rest-starter

Or via npm/yarn/pnpm when using the Node-compatible runtime:

npm install @setu-ts/rest-starter

Usage

The starter exports a single factory function createRestApp that returns a fully wired application with error handling already configured:

import { createRestApp } from '@setu-ts/rest-starter';

const app = createRestApp();

app.router.get('/hello', (ctx) => ctx.response.text('Hello world'));

await app.start({ port: 3000 });

With Options

You can customize plugin configuration through the optional options parameter:

import { createRestApp } from '@setu-ts/rest-starter';
import type { RestStarterOptions } from '@setu-ts/rest-starter';

// Read from wherever the host supplies secrets: `Deno.env.get(...)` on Deno,
// `process.env...` on Node/Bun, or the `env` argument on Cloudflare Workers.
declare const jwtSecret: string;
declare const sessionSecret: string;

const options: RestStarterOptions = {
  config: {/* config plugin options */},
  logger: {/* logger plugin options */},
  validation: {/* validation plugin options */},
  httpSecurity: {/* http-security plugin options */},
  health: {/* health plugin options */},
  metrics: {/* metrics plugin options */},
  openapi: {/* openapi plugin options */},
  serviceDiscovery: { provider: 'static', services: {/* service endpoint map */} },
  decorators: {/* decorator plugin options */},
  database: { type: 'memory' },
  // Pick any passive strategy; AuthPlugin installs its middleware globally.
  auth: { jwt: { secret: jwtSecret } },
  session: { secret: sessionSecret },
  di: {/* di plugin options */},
  realtime: {
    websocket: {/* websocket plugin options */},
    sse: {/* sse plugin options */},
    backplane: {/* realtime-backplane plugin options */},
  },
};

const app = createRestApp(options);

serviceDiscovery is optional. When supplied it registers one ServiceDiscoveryPlugin; because the microservice and full-stack option types extend RestStarterOptions, the same arm composes through those starters without a second registration path.

Advanced Plugin Composition

For scenarios requiring custom plugin ordering or selective inclusion, use the buildRestPlugins builder function together with createApplication from the kernel:

import { buildRestPlugins } from '@setu-ts/rest-starter';
import { createApplication } from '@setu-ts/kernel';

const app = createApplication({
  plugins: buildRestPlugins({
    database: { type: 'memory' }, // provide options object; omit to exclude
    auth: {
      jwt: { secret: 'test-secret' },
      rbac: { roles: {} },
    },
  }),
});

Options

Swagger UI is self-hosted by default. Install swagger-ui-dist (^5) in the consuming application; Deno also needs filesystem read permission. All starter families forward openapi: createRestApp({ openapi: { swagger: { assets: 'cdn' } } }) selects pinned CDN assets. On Workers, which has no filesystem to self-host from, the starter selects them itself when no Swagger asset mode is chosen; an explicit choice, such as assets: { baseUrl: '/assets' }, is kept. Omitting the package while self-hosting fails startup with OptionalDependencyUnavailableError.

RestStarterOptions is one optional field per plugin. Every field is optional and createRestApp() with no arguments is valid.

Eight fields configure a plugin the starter always registers — omitting one takes that plugin’s defaults:

OptionType
configConfigPluginOptions
loggerLoggerPluginOptions
validationValidationPluginOptions
httpSecurityHttpSecurityPluginOptions
healthHealthPluginOptions
metricsMetricsPluginOptions
openapiOpenApiPluginOptions
decoratorsDecoratorPluginOptions

The remaining seven are gated arms — supplying one registers a plugin that is otherwise absent, so the default composition never bundles something unusable:

OptionTypeRegisters when supplied
databaseDatabasePluginOptionsDatabasePlugin
authAuthPluginOptionsAuthPlugin; needs one passive strategy and registers authentication middleware globally.
sessionSessionPluginOptionsSessionPlugin
diDiPluginOptionsDiPlugin, which changes how every decorated service is constructed
graphqlGraphqlPluginOptionsGraphqlPlugin
serviceDiscoveryServiceDiscoveryPluginOptionsServiceDiscoveryPlugin
realtimeRealtimeArmWebSocket, SSE, and backplane — one per sub-arm

Included Plugins

PluginDescription
RuntimePluginCore runtime integration
ConfigPluginConfiguration management
LoggerPluginStructured logging
ValidationPluginRequest/response validation
HttpSecurityPluginHTTP security headers
HealthPluginHealth check endpoints
MetricsPluginApplication metrics collection
OpenApiPluginOpenAPI/Swagger documentation
DecoratorPluginDecorator-based route registration
DatabasePluginOptional — database access layer
AuthPluginOptional — authentication middleware
DiPluginOptional — DI container
GraphqlPluginOptional — GraphQL endpoint
ServiceDiscoveryOptional — service resolution
WebSocketPluginOptional — WebSocket messaging
SsePluginOptional — Server-Sent Events
RealtimeBackplaneOptional — cross-replica fan-out
SessionPluginOptional — cookie sessions + form CSRF

Gated plugins (database, auth, session, di, graphql, serviceDiscovery, and each realtime sub-arm) are only included when explicitly provided in options.

The session arm

Adds SessionPlugin: cookie-backed sessions under CAPABILITIES.SESSION, and — with csrf — the synchronizer-token form-CSRF middleware at priority 325, which is the check a progressive-enhancement <Form> post can satisfy (the stateless Origin/Referer check in httpSecurity structurally cannot). Running both together is intended.

const app = createRestApp({
  session: { secret: Deno.env.get('SESSION_SECRET')!, csrf: {} },
});

It is gated rather than always-on because the secret cannot be defaulted: SessionPlugin throws during register() without an adequate one, so an always-on arm would stop every application from booting until it supplied one. A token-authenticated API needs no cookie at all.

The realtime arm

One option groups the three plugins that make a connection-oriented application work. Each sub-arm’s presence adds exactly its plugin; realtime: {} adds nothing and is not an error.

const app = createRestApp({
  realtime: {
    sse: {},
    websocket: {},
    // Without a backplane, rooms and channels reach only clients connected to
    // THIS replica, and both plugins log a scaling notice.
    backplane: { transport: 'redis', url: 'redis://localhost:6379' },
  },
});

The backplane registers at PLUGIN_PRIORITY.HIGH, so it precedes both consumers regardless of option order. backplane: {} selects the in-process 'memory' transport.

{ transport: 'messaging' } carries frames over whatever broker is registered under CAPABILITIES.MESSAGING. The REST tier registers no broker, so that combination throws at start() naming MessagingPlugin — use the microservice starter, which always registers one, or register MessagingPlugin yourself before starting.

On Node and Bun a WebSocket upgrade needs a real listening server, so under app.inject() the plugin registers with available: false. The same application code is correct on Deno and Workers.

The di arm

Supplying di registers DiPlugin, and that changes how every decorated service in the application is constructed: DecoratorPlugin branches on the presence of a container, so each @Injectable class becomes a container provider honoring its scope. Omitted (the default), those classes are constructed directly and registered in the kernel’s ServiceRegistry.

A token the container does not hold falls back to the kernel registry, because DiPlugin’s autoRegister defaults to true. That is what lets @Inject(CAPABILITIES.LOGGER), or any other framework capability, reach a decorated class. di: { autoRegister: false } turns the fallback off.

On 0.8.0 and earlier the default was false, so write di: { autoRegister: true } there.

That is why it is an arm rather than always-on — a starter app that never asks for it composes exactly as it did before the option existed.

Coming from NestJS

NestJSSetu-TS
@Module({ … })A plugin — IPlugin with provides: [CAPABILITIES.X]
providers: [UserService]decorators: { services: [UserService] }, or app.register(…)
@Injectable()@Injectable({ token, scope })
Constructor injection by type@Inject(token, …) on the class, one entry per argument
@Controller('/users')@Controller('/users') — identical
@Get() / @Post()@Get() / @Post() — identical
@Body() / @Query()@Params(Body(), Query()) — sources, listed in argument order
Guard (CanActivate)@UseGuards(fn), or an auth-plugin guard factory
Pipe (ValidationPipe)@ValidateBody(schema) (Zod)
Interceptor@UseInterceptors(fn)
Exception filter@UseFilters(fn), or errorHandler() middleware
imports: [OtherModule]ctx.services.get(CAPABILITIES.X) — no plugin imports another
DI is requiredDI is the optional di arm

The one difference that will bite you: constructor injection needs an explicit token.

import { Inject, Injectable } from '@setu-ts/decorator-plugin';
import { CAPABILITIES, type ILogger } from '@setu-ts/common';

@Injectable({ token: 'user-service' })
@Inject(CAPABILITIES.LOGGER)
class UserService {
  constructor(private logger: ILogger) {}
}

constructor(private db: DatabaseService) cannot work here. Inferring the token from the parameter’s type requires emitDecoratorMetadata, which Deno does not support — so the type is simply not available at runtime. This is permanent, not a gap waiting to be filled.

Two consequences follow from the list being positional:

  • The Nth entry binds the Nth constructor argument, so reordering the constructor without reordering the tokens misinjects every argument — they are one declaration and move together.
  • A list shorter than the constructor leaves the trailing arguments undefined; a positional list cannot have gaps. Method parameters are separate, and bind with @Params(...).

Workers Portability

All plugins bundled by default in this starter are compatible with Cloudflare Workers (edge runtime). The default REST starter is fully Workers-portable — every plugin uses only standard Web APIs (fetch, Request, Response) and has no filesystem or network-socket dependencies. You can deploy an app built with rest-starter directly to Workers via export default { fetch: app.fetch }.

The gated arms vary. di, realtime.websocket, realtime.sse, and a 'memory' backplane are all Workers-portable. A 'redis' backplane is not — it needs a raw socket. A 'messaging' backplane is portable only if the broker under CAPABILITIES.MESSAGING is.

Multi-instance Restriction + Escape Hatch

The four multi-instance plugins (cache, database, queue, messaging) accept an options.name parameter that creates a derived capability token. The starter registers one instance per arm on the bare token (e.g., CAPABILITIES.CACHE). Setting name through a starter arm moves the plugin off the bare token, which will break any code that resolves the capability (including health checks and documentation examples).

The starter does not support setting name through its option arms. If you need a second instance (e.g., a session cache distinct from the default), register it manually after the starter returns:

import { createRestApp } from '@setu-ts/rest-starter';
import { CachePlugin } from '@setu-ts/cache-plugin';

const app = createRestApp();
app.register(CachePlugin({ name: 'session' }));

This escape hatch works because createRestApp returns an un-started IKernelApplication that accepts additional registrations.

See Also

Exports

ExportKind
buildRestPluginsfunction
createRestAppfunction
RealtimeArminterface
RestStarterOptionsinterface

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

Agent card · TypeDoc API reference