@setu-ts/full-stack-starter

Opinionated plugin composition for building full-stack applications with Setu-TS.

The most comprehensive starter bundle, combining REST capabilities, microservice patterns, and full-stack features including caching, event-driven architecture, CQRS, scheduling, auditing, secrets management, storage, mail delivery, feature flags, notifications, multi-tenancy, and React SSR support.

Installation

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

Or via npm/yarn/pnpm:

npm install @setu-ts/full-stack-starter

Usage

The starter exports createFullStackApp — a fully wired application with all enterprise-grade plugins pre-configured:

import { createFullStackApp } from '@setu-ts/full-stack-starter';

const app = createFullStackApp();

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

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

With Options

Configure every plugin through the optional options parameter:

import { createFullStackApp } from '@setu-ts/full-stack-starter';
import type { FullStackStarterOptions } from '@setu-ts/full-stack-starter';

const options: FullStackStarterOptions = {
  // REST base plugins (see rest-starter)
  config: {/* ... */},
  logger: {/* ... */},

  // Microservice additions (see microservice-starter)
  messaging: {/* ... */},
  queue: {/* ... */},

  // Full-stack additions
  cache: {/* cache plugin options */},
  events: {/* events plugin options */},
  cqrs: {/* cqrs plugin options */},
  scheduler: {/* scheduler plugin options */},
  audit: {/* audit plugin options */},
  secrets: {/* secrets plugin options */},
  storage: {/* storage plugin options */},
  mail: {/* mail plugin options */},

  // Gated arms (only included when provided)
  featureFlags: { provider: 'memory' },
  notifications: { channels: {} },
  multiTenancy: { resolver: 'header' },
  reactRouter: { serverBuildPath: './build/server/index.js' },
  static: { root: './build/client', urlPrefix: '/assets' },
};

const app = createFullStackApp(options);

Advanced Plugin Composition

Use buildFullStackPlugins together with createApplication from the kernel to construct a custom plugin array for advanced scenarios requiring selective inclusion or different ordering:

import { buildFullStackPlugins } from '@setu-ts/full-stack-starter';
import { createApplication } from '@setu-ts/kernel';

const app = createApplication({
  plugins: buildFullStackPlugins({
    cache: {}, // provide options object; omit to use default memory store
    events: {}, // provide options object; omit to use in-memory bus default
    reactRouter: { serverBuildPath: './build/server/index.js' },
    // Omit featureFlags, notifications, multiTenancy if not needed
  }),
});

Included Plugins

CategoryPluginDescription
REST BaseRuntimePluginCore runtime integration
ConfigPluginConfiguration management
LoggerPluginStructured logging
ValidationPluginRequest validation
HttpSecurityPluginSecurity headers
HealthPluginHealth check endpoints
MetricsPluginMetrics collection
OpenApiPluginOpenAPI documentation
DecoratorPluginDecorator-based routing
DatabasePluginOptional database access
AuthPluginOptional authentication
MicroserviceMessagingPluginAsync message bus support
QueuePluginBackground job queueing
ResiliencePluginCircuit breaker & retries
TelemetryPluginTracing & observability
Full-StackCachePluginDistributed caching
EventsPluginEvent publishing/subscribing
CqrsPluginCQRS pattern support
SchedulerPluginScheduled task execution
AuditPluginAudit trail logging
SecretsPluginSecure secret management
StoragePluginObject/file storage
MailPluginEmail delivery
FeatureFlagsPluginDynamic feature toggles
NotificationPluginPush/notification service
MultiTenancyPluginTenant isolation
ReactRouterPluginReact SSR & file-based routing

OpenApiPlugin self-hosts Swagger UI by default, so install swagger-ui-dist (^5) in the consuming application, or pass openapi: { swagger: { assets: 'cdn' } }. Without either, startup fails with OptionalDependencyUnavailableError. On Cloudflare Workers, which has no filesystem to self-host from, the starter selects the CDN itself when no Swagger asset mode is chosen.

The inherited auth arm accepts any AuthPluginOptions with at least one passive strategy (jwt, apiKey, session, or strategies). When present, AuthPlugin installs authentication middleware globally at priority 300 unless auth.middleware disables or moves it.

Gated plugins (featureFlags, notifications, multiTenancy, reactRouter, static) are only registered when explicitly provided in options. The static arm exists because this is the one tier that by definition serves a browser: supplying it registers StaticPlugin with the given options (typically root: './build/client' so hashed assets and public/ files are served beside SSR); omitting it registers nothing, so the default composition stays byte-identical to before the option existed. A root-level urlPrefix claims the bare wildcard and would collide with the SSR catch-all — give static files their own prefix.

Workers Portability

With default options the starter boots on Cloudflare Workers. Every always-on plugin defaults to an arm that needs no socket or filesystem: memory messaging, queue and storage, log mail, and environment secrets. One plugin is different: SchedulerPlugin refuses Workers at startup, because its timers do not survive isolate eviction, so the starter leaves it out there. Schedule on Workers with Cron Triggers (cloudflare-plugin’s WorkersCron). Passing a scheduler arm registers the plugin anyway, and it then fails with an error that names that remedy.

The memory arms are process-local, and a Workers isolate is short-lived, so they carry nothing between isolates. For queues and messaging that must reach another isolate or service, use cloudflare-plugin’s Queues-backed WorkersQueue and WorkersBroker. Arms that need sockets or a filesystem (SMTP mail, local storage, the Redis and broker drivers) do not run on Workers.

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, CAPABILITIES.MESSAGING). 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, or a separate queue for dead-letter processing), register it manually after the starter returns:

import { createFullStackApp } from '@setu-ts/full-stack-starter';
import { CachePlugin } from '@setu-ts/cache-plugin';
import { QueuePlugin } from '@setu-ts/queue-plugin';

const app = createFullStackApp();
app.register(CachePlugin({ name: 'session' }));
app.register(QueuePlugin({ name: 'dead-letter' }));

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

Realtime and DI arms

createFullStackApp inherits the realtime, di, and optional serviceDiscovery arms from the REST starter — the option type extends RestStarterOptions, so every sub-arm behaves identically and nothing new is registered by default.

const app = createFullStackApp({
  di: {},
  serviceDiscovery: { provider: 'static', services: {} },
  realtime: { sse: {}, websocket: {}, backplane: { transport: 'messaging' } },
});

{ transport: 'messaging' } works on this tier without extra wiring, because the microservice set it composes from always registers MessagingPlugin.

None of these arms collide with the plugins this tier already bundles — sse, websocket, di, and the backplane are registered by no other arm. di: {} builds a container that falls back to the kernel registry, so decorated classes can inject framework capabilities; see Constructor injection in loaders.

The session arm is inherited the same way: session: { secret, csrf: {} } adds cookie sessions and the form-CSRF middleware, which a server-rendered <Form> post needs.

See rest-starter for the full description of each arm.

Constructor injection in loaders

React Router builds its loaders and actions itself, so the container never constructs them. A loader reaches an injected service the same way it reaches the session: populateLoadContext resolves it for each request and puts it on a context key the loader reads.

import { Inject, Injectable } from '@setu-ts/decorator-plugin';
import { CAPABILITIES, type IContainer, type ILogger } from '@setu-ts/common';
import { createFullStackAppFromConfig } from '@setu-ts/full-stack-starter';
import { contextKeyFor } from '@setu-ts/react-router-plugin';

@Injectable({ token: 'pricing-service', scope: 'singleton' })
@Inject(CAPABILITIES.LOGGER)
export class PricingService {
  constructor(private readonly logger: ILogger) {}
  quote(cents: number): string {
    this.logger.debug('pricing: quote');
    return `$${(cents / 100).toFixed(2)}`;
  }
}

// Declare it beside the template's other keys in app/lib/context-keys.server.ts:
// contextKeyFor reuses one key per name; separate { defaultValue: null } objects would not.
export const pricingContext = contextKeyFor<PricingService | null>('app.pricing', null);

const app = await createFullStackAppFromConfig(() => ({
  di: {},
  decorators: { services: [PricingService] },
  reactRouter: {
    serverBuildPath: new URL('./build/server/index.js', import.meta.url).href,
    populateLoadContext: (ctx, context) => {
      const container = ctx.services.get<IContainer>(CAPABILITIES.DI_CONTAINER);
      context.set(pricingContext, container.resolve<PricingService>('pricing-service'));
    },
  },
}));

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

A loader then calls context.get(pricingContext)?.quote(4900).

Three things to know:

  • di: {} falls back to the kernel registry. DiPlugin’s autoRegister defaults to true, which is what lets @Inject(CAPABILITIES.LOGGER) find the framework’s own services. Earlier releases defaulted it to false, so write di: { autoRegister: true } there, or every page answers 500 with No provider registered for DI token 'logger'.
  • Each request has a DI scope. container.forRequest(ctx) returns the request’s scope, created on first use and disposed when the response is finished, so a scoped service resolved through it is one instance per request. Resolving a scoped service on the root container throws ScopeRequiredError.
  • A DI mistake in a resolve of your own fails every page, not startup. Since 0.11.0 the container refuses a broken graph at start() — a missing dependency, a cycle, or a singleton that captures a scoped service. But a token that only populateLoadContext resolves is not part of any graph: a misspelled token there, an unlisted service, or a missing di arm starts cleanly and answers /health with 200, then throws on every server-rendered request. The smoke test setu new generates requests / as well as /health, so the project’s own test script catches it; keep that request in.

Composing from configuration

Plugin options must be decided before the plugins are constructed — which is before ConfigPlugin has registered anything, so ctx.services.get(CAPABILITIES.CONFIG) is not available yet. createFullStackAppFromConfig closes that ordering gap for every option at once:

import {
  createFullStackAppFromConfig,
  fullStackConfigOf,
  type FullStackStarterOptions,
} from '@setu-ts/full-stack-starter';

const app = await createFullStackAppFromConfig(
  (config): FullStackStarterOptions => ({
    // `prismaClient` is generated and constructed by the application — a Prisma v7
    // client carries its own connection configuration, so `url` is not an option.
    database: { type: 'prisma', options: { prismaClient } },
    session: { secret: config.getOrThrow<string>('SESSION_SECRET'), csrf: {} },
    messaging: {
      broker: 'redis-streams',
      url: config.get<string>('REDIS_URL', { default: 'redis://127.0.0.1:6379' }),
    },
    queue: {
      adapter: 'redis',
      url: config.get<string>('REDIS_URL', { default: 'redis://127.0.0.1:6379' }),
    },
  }),
  { config: { envFilePath: ['.env.local', '.env'] } },
);

const config = fullStackConfigOf(app); // the exact snapshot passed to the callback

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

The Redis options above require ioredis installed in the application. The CLI supplies that client and these options with setu new portal --template full-stack --broker redis --queue redis, or from a workspace’s --transport redis when generating a full-stack member. The same config read sees dotenv and deployment environment values; omitting the options keeps the starter’s memory defaults.

Configuration is loaded once and the same snapshot is registered under CAPABILITIES.CONFIG, so the values the composition branched on are the values handlers read — not a second snapshot taken a moment later. The resolver runs exactly once; if it throws, or configuration fails to load, the promise rejects and no partially-composed application exists.

Post-factory code reads that same object with fullStackConfigOf(app). Calling the accessor for an application built by another factory throws FullStackConfigUnavailableError.

This is why no plugin option carries a config-key shorthand such as urlFromConfig: that field would need its value at the same impossible moment. Secrets are further out of reach — they are served by secrets-plugin after registration, so a plugin needing one resolves it lazily at use time.

The React Router app skeleton

This package supplies the plugin composition; it cannot supply the application’s app/ directory, because a JSR library cannot write files into your project. Scaffold that with the CLI, which generates a setu.config.ts calling createFullStackAppFromConfig:

setu new my-app --template full-stack

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(...).

See Also

Exports

ExportKind
buildFullStackPluginsfunction
createFullStackAppfunction
createFullStackAppFromConfigfunction
fullStackConfigOffunction
FullStackConfigUnavailableErrorclass
FromConfigOptionsinterface
FullStackStarterOptionsinterface
RealtimeArminterface
StaticPluginOptionstype

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

Agent card · TypeDoc API reference