---
URL: "moost.org/"
LLMS_URL: "moost.org/index.md"
layout: "home"
title: "Moost"
titleTemplate: "Home | :title"
hero2:
name: "Moost"
text: "A TypeScript framework for HTTP, WebSocket, CLI, and workflows."
tl1: "NestJS-style structure."
tlhl: "No modules."
tl2: "No ceremony."
actions:
- theme: "brand"
text: "Get Started"
link: "/moost/"
- theme: "alt"
text: "Why Switch from NestJS"
link: "/moost/why"
- theme: "alt"
text: "Benchmarks"
link: "/webapp/benchmarks"
- theme: "alt"
text: "AI Skill"
link: "/moost/ai-skill"
---
## For LLMs
This documentation is available in LLM-friendly format at [llms.txt](https://moost.org/llms.txt) and [llms-full.txt](https://moost.org/llms-full.txt).
## AI Agent Skill
Moost provides a unified skill for AI coding agents (Claude Code, Cursor, Windsurf, Codex, etc.) that covers all packages with progressive-disclosure reference docs.
```bash
npx skills add moostjs/moostjs
```
Learn more about AI agent skills at [skills.sh](https://skills.sh).
---
URL: "moost.org/cliapp"
LLMS_URL: "moost.org/cliapp.md"
---
# Getting Started
Build a CLI application with Moost in under a minute.
## Prerequisites
- Node.js 18 or higher
- npm, pnpm, or yarn
## Scaffold a project
```bash
npm create moost -- --cli
```
Or with a project name:
```bash
npm create moost my-cli -- --cli
```
The scaffolder creates:
```
my-cli/
├── src/
│ ├── controllers/
│ │ └── app.controller.ts
│ ├── main.ts
│ └── bin.ts
├── package.json
└── tsconfig.json
```
## What you get
**main.ts** — the entry point:
```ts
import { CliApp } from '@moostjs/event-cli'
import { AppController } from './controllers/app.controller'
new CliApp()
.controllers(AppController)
.useHelp({ name: 'my-cli' })
.start()
```
**app.controller.ts** — your first command:
```ts
import { Cli, Param, Controller } from '@moostjs/event-cli'
@Controller()
export class AppController {
@Cli('greet/:name')
greet(@Param('name') name: string) {
return `Hello, ${name}!`
}
}
```
## Run it
```bash
npm install && npx tsx src/bin.ts greet World
```
You'll see `Hello, World!` in the terminal.
## How it works
1. `new CliApp()` creates a Moost instance pre-configured for CLI
2. `.controllers()` registers classes that contain command handlers
3. `.useHelp()` enables the built-in help system (try `--help`)
4. `.start()` wires everything together and runs the command from `process.argv`
The `@Cli('greet/:name')` decorator registers the method as a CLI command. The `:name` segment becomes a positional argument, extracted by `@Param('name')`.
## AI Agent Skill
Install the unified Moost AI skill for context-aware assistance in AI coding agents (Claude Code, Cursor, Windsurf, Codex, etc.):
```bash
npx skills add moostjs/moostjs
```
## What's next
- [Commands](./commands) — command paths, aliases, and routing patterns
- [Options & Arguments](./options) — flags, positional args, and boolean options
- [Controllers](./controllers) — organize commands into logical groups
---
URL: "moost.org/cliapp/advanced.html"
LLMS_URL: "moost.org/cliapp/advanced.md"
---
# Advanced
This page covers topics you won't need for most CLI apps but that become useful as your project grows: manual adapter configuration, Wooks composables, dependency injection scopes, and running CLI and HTTP from the same codebase.
## MoostCli adapter (manual setup)
`CliApp` is a convenience wrapper. For full control, use `MoostCli` directly:
```ts
import { MoostCli, cliHelpInterceptor } from '@moostjs/event-cli'
import { Moost } from 'moost'
import { AppController } from './controllers/app.controller'
const app = new Moost()
app.applyGlobalInterceptors(
cliHelpInterceptor({
colors: true,
lookupLevel: 3,
}),
)
app.registerControllers(AppController)
app.adapter(new MoostCli({
wooksCli: {
cliHelp: { name: 'my-cli' },
},
globalCliOptions: [
{ keys: ['help'], description: 'Display instructions for the command.' },
],
}))
void app.init()
```
### MoostCli options
| Option | Type | Description |
|--------|------|-------------|
| `wooksCli` | `WooksCli \| object` | A WooksCli instance or configuration options |
| `debug` | `boolean` | Enable internal logging (default: `false`) |
| `globalCliOptions` | `array` | Options shown in help for every command |
When `debug` is `false` (default), DI container logging is suppressed for a clean terminal.
### When to use MoostCli vs CliApp
| Use case | Recommendation |
|----------|---------------|
| Standard CLI app | `CliApp` — less boilerplate |
| Need a pre-configured WooksCli instance | `MoostCli` — pass your own instance |
| Multiple adapters (CLI + HTTP) | `MoostCli` — attach to existing Moost instance |
| Custom error handling on WooksCli | `MoostCli` — configure `wooksCli.onError` |
## Composables
Moost CLI builds on [Wooks](https://wooks.moost.org/), which provides composable functions for reading CLI context inside handlers. These work anywhere within the handler call chain — in the handler itself, in services, or in interceptors.
### useCliOption(name)
Read a single option value. This is what `@CliOption()` uses under the hood:
```ts
import { useCliOption } from '@wooksjs/event-cli'
@Cli('build')
build() {
const verbose = useCliOption('verbose')
if (verbose) console.log('Verbose mode on')
return 'Building...'
}
```
### useCliOptions()
Read all parsed options at once:
```ts
import { useCliOptions } from '@wooksjs/event-cli'
@Cli('build')
build() {
const opts = useCliOptions()
// opts: { verbose: true, target: 'production', ... }
return `Building with ${JSON.stringify(opts)}`
}
```
### useRouteParams()
Read positional arguments. This is what `@Param()` uses under the hood:
```ts
import { useRouteParams } from '@wooksjs/event-core'
@Cli('deploy/:env')
deploy() {
const params = useRouteParams()
return `Deploying to ${params.get('env')}`
}
```
### useCliHelp()
Access the help renderer programmatically:
```ts
import { useCliHelp } from '@wooksjs/event-cli'
@Cli('info')
info() {
const { print } = useCliHelp()
print(true) // print help with colors
}
```
## Dependency injection scopes
Moost's DI container supports two scopes, both relevant to CLI:
| Scope | Behavior | CLI use case |
|-------|----------|-------------|
| `SINGLETON` | One instance for the entire app lifetime | Shared config, database connections |
| `FOR_EVENT` | New instance per CLI command execution | Command-specific state |
```ts
import { Injectable } from 'moost'
@Injectable('SINGLETON')
export class ConfigService {
readonly env = process.env.NODE_ENV ?? 'development'
}
@Injectable('FOR_EVENT')
export class CommandState {
startedAt = Date.now()
}
```
Controllers default to `SINGLETON` scope. For most CLI apps this is correct — each command invocation uses the same controller instance, with fresh argument values resolved per call.
::: tip
See [Dependency Injection](/moost/di/) for constructor injection, circular dependencies, and advanced patterns.
:::
## Multi-adapter: CLI + HTTP
A single Moost instance can serve both CLI commands and HTTP routes. Shared controllers work across adapters — decorate methods with both `@Cli()` and `@Get()`:
```ts
import { Cli, Controller, Param } from '@moostjs/event-cli'
import { Get } from '@moostjs/event-http'
@Controller('health')
export class HealthController {
@Cli('check')
@Get('check')
check() {
return { status: 'ok', uptime: process.uptime() }
}
}
```
Wire up both adapters on the same Moost instance:
```ts
import { MoostCli } from '@moostjs/event-cli'
import { MoostHttp } from '@moostjs/event-http'
import { Moost } from 'moost'
const app = new Moost()
app.registerControllers(HealthController)
// CLI adapter
app.adapter(new MoostCli())
// HTTP adapter
app.adapter(new MoostHttp()).listen(3000)
void app.init()
```
Now `my-cli health check` and `GET /health/check` both invoke the same handler.
## Event type identifier
The `cliKind` export identifies CLI events in the Wooks context system. This is useful when writing adapters or composables that need to distinguish event types:
```ts
import { cliKind } from '@moostjs/event-cli'
```
## What's next
- [Interceptors guide](/moost/interceptors) — full interceptor lifecycle and class-based patterns
- [Dependency Injection](/moost/di/) — scopes, constructor injection, circular dependencies
- [Pipes & Validation](/moost/pipes/) — transform and validate command arguments
---
URL: "moost.org/cliapp/commands.html"
LLMS_URL: "moost.org/cliapp/commands.md"
---
# Commands
Every CLI command in Moost is a method decorated with `@Cli()`. The decorator argument defines the command path — the words a user types to invoke it.
## Defining a command
```ts
import { Cli, Controller } from '@moostjs/event-cli'
@Controller()
export class AppController {
@Cli('deploy')
deploy() {
return 'Deploying...'
}
}
```
Running `my-cli deploy` calls the `deploy()` method and prints the return value.
## Command paths
A command path is a sequence of segments separated by spaces or slashes (both are equivalent):
```ts
// These two are identical:
@Cli('config set')
@Cli('config/set')
```
The user always types space-separated words:
```bash
my-cli config set
```
## Positional arguments
Segments that start with `:` become positional arguments:
```ts
@Cli('greet/:name')
greet(@Param('name') name: string) {
return `Hello, ${name}!`
}
```
```bash
my-cli greet World # → Hello, World!
```
Multiple arguments work the same way:
```ts
@Cli('copy/:source/:dest')
copy(
@Param('source') source: string,
@Param('dest') dest: string,
) {
return `Copying ${source} → ${dest}`
}
```
```bash
my-cli copy fileA.txt fileB.txt
```
## Escaped colons
If a command name contains a literal colon (like `build:dev`), escape it with a backslash:
```ts
@Cli('build\\:dev')
buildDev() {
return 'Building for dev...'
}
```
```bash
my-cli build:dev
```
## Default path from method name
When `@Cli()` is called without an argument, the method name becomes the command:
```ts
@Cli()
status() {
return 'All systems operational'
}
```
```bash
my-cli status
```
## Command aliases
Use `@CliAlias()` to define alternative names for a command. Stack multiple decorators for multiple aliases:
```ts
import { Cli, CliAlias, Controller } from '@moostjs/event-cli'
@Controller()
export class AppController {
@Cli('install/:package')
@CliAlias('i/:package')
@CliAlias('add/:package')
install(@Param('package') pkg: string) {
return `Installing ${pkg}...`
}
}
```
All three invoke the same handler:
```bash
my-cli install lodash
my-cli i lodash
my-cli add lodash
```
## Return values
Whatever a command handler returns is printed to stdout. Return a string for plain text, or an object/array for JSON output:
```ts
@Cli('info')
info() {
return { name: 'my-app', version: '1.0.0' }
}
```
```bash
my-cli info
# → {"name":"my-app","version":"1.0.0"}
```
## What's next
- [Options & Arguments](./options) — add flags like `--verbose` and typed parameters
- [Controllers](./controllers) — group commands under prefixes
---
URL: "moost.org/cliapp/controllers.html"
LLMS_URL: "moost.org/cliapp/controllers.md"
---
# Controllers
Controllers group related commands under a shared prefix. This keeps your code organized and creates natural command hierarchies — like `git remote add` or `docker compose up`.
## Basic controller
A controller is a class decorated with `@Controller()`. The optional argument sets a command prefix:
```ts
import { Cli, Controller, Param } from '@moostjs/event-cli'
@Controller('user')
export class UserController {
@Cli('create/:name')
create(@Param('name') name: string) {
return `Created user: ${name}`
}
@Cli('delete/:name')
delete(@Param('name') name: string) {
return `Deleted user: ${name}`
}
}
```
The prefix `user` is prepended to each command:
```bash
my-cli user create Alice
my-cli user delete Bob
```
Without a prefix (`@Controller()`), commands are registered at the root level.
## Registering controllers
With `CliApp`, use the `.controllers()` method:
```ts
import { CliApp } from '@moostjs/event-cli'
import { UserController } from './user.controller'
import { ConfigController } from './config.controller'
new CliApp()
.controllers(UserController, ConfigController)
.useHelp({ name: 'my-cli' })
.start()
```
Or with the standard Moost setup:
```ts
const app = new Moost()
app.registerControllers(UserController, ConfigController)
```
## Nesting controllers
Use `@ImportController()` to nest one controller inside another. The child's prefix is appended to the parent's:
```ts
import { Cli, Controller, Param } from '@moostjs/event-cli'
import { ImportController } from 'moost'
@Controller('view')
export class ViewCommand {
@Cli(':id')
view(@Param('id') id: string) {
return `Viewing profile ${id}`
}
}
@Controller('profile')
@ImportController(ViewCommand)
export class ProfileController {
@Cli('list')
list() {
return 'Listing profiles...'
}
}
```
Register only the top-level controller — imported children are registered automatically:
```ts
new CliApp()
.controllers(ProfileController)
.start()
```
This produces:
```bash
my-cli profile list # ProfileController.list()
my-cli profile view 42 # ViewCommand.view("42")
```
## Multi-level nesting
You can nest as deep as needed. Here's a `git`-like command structure:
```ts
@Controller('add')
class RemoteAddController {
@Cli(':name/:url')
add(
@Param('name') name: string,
@Param('url') url: string,
) {
return `Adding remote ${name} → ${url}`
}
}
@Controller('remote')
@ImportController(RemoteAddController)
class RemoteController {
@Cli('list')
list() {
return 'Listing remotes...'
}
}
@Controller()
@ImportController(RemoteController)
class GitController {
@Cli('status')
status() {
return 'On branch main'
}
}
```
```bash
my-cli status # GitController
my-cli remote list # RemoteController
my-cli remote add origin git@... # RemoteAddController
```
## Path composition
The final command path is built by joining: **controller prefix** + **child prefix** + **command path**.
| Parent prefix | Child prefix | `@Cli()` path | Final command |
|--------------|-------------|---------------|---------------|
| `''` (root) | — | `status` | `status` |
| `remote` | — | `list` | `remote list` |
| `remote` | `add` | `:name/:url` | `remote add :name :url` |
## What's next
- [Help System](./help) — document commands with descriptions, examples, and auto-help
- [Interceptors](./interceptors) — add guards, logging, and error handling
---
URL: "moost.org/cliapp/help.html"
LLMS_URL: "moost.org/cliapp/help.md"
---
# Help System
A well-built CLI is self-documenting. Moost provides decorators that generate rich `--help` output automatically — descriptions, usage patterns, argument docs, option lists, and examples.
## Describing commands
Use `@Description()` on a method to explain what a command does:
```ts
import { Cli, Controller, Description } from '@moostjs/event-cli'
@Controller()
export class AppController {
@Description('Deploy the application to a target environment')
@Cli('deploy/:env')
deploy(@Param('env') env: string) {
return `Deploying to ${env}...`
}
}
```
The description appears at the top of `--help` output for that command.
## Describing arguments and options
Apply `@Description()` to parameters as well:
```ts
@Description('Deploy the application to a target environment')
@Cli('deploy/:env')
deploy(
@Description('Target environment (production, staging, dev)')
@Param('env')
env: string,
@Description('Project name to deploy')
@CliOption('project', 'p')
project: string,
) {
return `Deploying ${project} to ${env}`
}
```
Each parameter's description appears under the **ARGUMENTS** or **OPTIONS** section in help.
## Usage examples
Add `@CliExample()` to show real usage patterns. Stack multiple decorators for multiple examples:
```ts
import { Cli, CliExample, CliOption, Controller, Description, Param } from '@moostjs/event-cli'
@Controller()
export class DeployController {
@Description('Deploy application to a target environment')
@Cli('deploy/:env')
@CliExample('deploy dev -p my-app', 'Deploy "my-app" to development')
@CliExample('deploy prod -p my-app --verbose', 'Deploy with verbose logging')
deploy(
@Description('Environment')
@Param('env')
env: string,
@Description('Project name')
@CliOption('project', 'p')
project: string,
) {
return `Deploying ${project} to ${env}`
}
}
```
Running `my-cli deploy --help` produces:
terminal
DESCRIPTION
Deploy application to a target environment
USAGE
$ my-cli deploy <env>
ARGUMENTS
<env> • Environment
OPTIONS
--help • Display instructions for the command.
-p, --project • Project name
EXAMPLES
# Deploy "my-app" to development
$ my-cli deploy dev -p my-app
# Deploy with verbose logging
$ my-cli deploy prod -p my-app --verbose
## Sample values
Use `@Value()` to display a placeholder next to options in help. This is purely cosmetic — it doesn't set a default:
```ts
import { Value } from 'moost'
@Cli('test/:config')
test(
@Description('Target environment')
@CliOption('target', 't')
@Value('')
target: string,
) {
return `Testing in ${target}`
}
```
In help output, the option renders as `--target `.
## Global options
Some options (like `--help` or `--verbose`) should appear in every command's help. There are two ways to define them.
### Via CliApp setup
Pass global options when initializing the app:
```ts
new CliApp()
.controllers(AppController)
.useHelp({ name: 'my-cli' })
.useOptions([
{ keys: ['help'], description: 'Display instructions for the command.' },
{ keys: ['verbose', 'v'], description: 'Enable verbose output.' },
])
.start()
```
### Via @CliGlobalOption decorator
Apply `@CliGlobalOption()` to a controller class. The option appears in help for every command in that controller:
```ts
import { Cli, CliGlobalOption, Controller } from '@moostjs/event-cli'
@Controller('build')
@CliGlobalOption({
keys: ['verbose', 'v'],
description: 'Enable verbose logging',
})
export class BuildController {
@Cli('dev')
buildDev() { return 'Building for dev...' }
@Cli('prod')
buildProd() { return 'Building for prod...' }
}
```
Both `build dev --help` and `build prod --help` will show the `--verbose` option.
## Enabling the help interceptor
The help system is powered by `cliHelpInterceptor`. With `CliApp`, calling `.useHelp()` sets it up automatically:
```ts
new CliApp()
.controllers(AppController)
.useHelp({
name: 'my-cli', // shown in usage examples: "$ my-cli ..."
colors: true, // colored output (default: true)
lookupLevel: 3, // fuzzy command lookup depth (default: 3)
})
.start()
```
### Help options reference
| Option | Default | Description |
|--------|---------|-------------|
| `name` | — | CLI app name shown in usage examples |
| `title` | — | Title displayed at the top of help |
| `colors` | `true` | Enable colored terminal output |
| `lookupLevel` | `3` | Depth for fuzzy "did you mean?" suggestions |
| `maxWidth` | — | Maximum width of help output |
| `maxLeft` | — | Maximum width of the left column |
| `mark` | — | Prefix marker for help sections |
## Unknown command handling
When `lookupLevel` is set, typing a wrong command triggers a "did you mean?" suggestion:
```bash
$ my-cli depoly
Did you mean "deploy"?
```
The lookup searches through registered commands up to the specified depth.
## Decorator form
You can also apply the help interceptor as a decorator on a controller or method using `@CliHelpInterceptor()`:
```ts
import { CliHelpInterceptor } from '@moostjs/event-cli'
@CliHelpInterceptor({ colors: true, lookupLevel: 3 })
@Controller()
export class AppController {
@Cli('deploy')
deploy() { return 'Deploying...' }
}
```
This is useful when you want help behavior scoped to specific controllers rather than applied globally.
## What's next
- [Interceptors](./interceptors) — add guards, error handling, and cross-cutting logic
- [Advanced](./advanced) — manual adapter setup, composables, and DI
---
URL: "moost.org/cliapp/interceptors.html"
LLMS_URL: "moost.org/cliapp/interceptors.md"
---
# Interceptors
Interceptors wrap command execution with cross-cutting logic — guards, logging, error formatting, timing, and more. They work identically to [Moost interceptors](/moost/interceptors) but here we focus on CLI-specific patterns.
## Quick recap
Interceptors have three lifecycle hooks — `before`, `after`, and `error`. Use the `define*` helpers to create them:
```ts
import { defineBeforeInterceptor, TInterceptorPriority } from 'moost'
// Runs before the handler — call reply(value) to skip the handler
const guard = defineBeforeInterceptor((reply) => {
// check something, throw or call reply() to short-circuit
}, TInterceptorPriority.GUARD)
```
```ts
import { defineAfterInterceptor } from 'moost'
// Runs after the handler — receives the response
const logger = defineAfterInterceptor((response) => {
console.log('Command returned:', response)
})
```
```ts
import { defineErrorInterceptor } from 'moost'
// Runs on error — receives the error, call reply(value) to recover
const errorHandler = defineErrorInterceptor((error, reply) => {
console.error(`Error: ${error.message}`)
reply('')
})
```
Or combine all hooks with `defineInterceptor`:
```ts
import { defineInterceptor, TInterceptorPriority } from 'moost'
const myInterceptor = defineInterceptor({
before(reply) { /* ... */ },
after(response, reply) { /* ... */ },
error(error, reply) { /* ... */ },
}, TInterceptorPriority.INTERCEPTOR)
```
::: tip
See the [Interceptors guide](/moost/interceptors) for the full lifecycle, priority levels, and class-based interceptors.
:::
## CLI guard example
A guard is a before-interceptor at `GUARD` priority. Throw or call `reply()` to stop execution:
```ts
import { defineBeforeInterceptor, TInterceptorPriority } from 'moost'
const requireEnvGuard = defineBeforeInterceptor(() => {
if (!process.env.CI_TOKEN) {
console.error('Error: CI_TOKEN environment variable is required')
process.exit(1)
}
}, TInterceptorPriority.GUARD)
```
Apply it to a specific command:
```ts
import { Cli, Controller, Intercept } from '@moostjs/event-cli'
@Controller()
export class DeployController {
@Intercept(requireEnvGuard)
@Cli('deploy/:env')
deploy(@Param('env') env: string) {
return `Deploying to ${env}...`
}
}
```
Or to every command in a controller:
```ts
@Intercept(requireEnvGuard)
@Controller('deploy')
export class DeployController {
@Cli('staging')
staging() { return 'Deploying to staging...' }
@Cli('production')
production() { return 'Deploying to production...' }
}
```
## Error handler
Catch errors and format them for the terminal:
```ts
import { defineErrorInterceptor, TInterceptorPriority } from 'moost'
const cliErrorHandler = defineErrorInterceptor((error, reply) => {
console.error(`Error: ${error.message}`)
reply('')
}, TInterceptorPriority.CATCH_ERROR)
```
Apply globally so every command benefits:
```ts
import { CliApp } from '@moostjs/event-cli'
const app = new CliApp()
app.applyGlobalInterceptors(cliErrorHandler)
app.controllers(AppController)
app.useHelp({ name: 'my-cli' })
app.start()
```
## Timing interceptor
Measure how long a command takes using a class-based interceptor with `FOR_EVENT` scope:
```ts
import { Interceptor, Before, After, TInterceptorPriority } from 'moost'
@Interceptor(TInterceptorPriority.BEFORE_ALL, 'FOR_EVENT')
class TimingInterceptor {
private start = 0
@Before()
recordStart() {
this.start = performance.now()
}
@After()
logDuration() {
const ms = (performance.now() - this.start).toFixed(1)
console.log(`Completed in ${ms}ms`)
}
}
```
Or as a functional interceptor:
```ts
import { defineInterceptor, TInterceptorPriority } from 'moost'
let start: number
const timingInterceptor = defineInterceptor({
before() {
start = performance.now()
},
after() {
const ms = (performance.now() - start).toFixed(1)
console.log(`Completed in ${ms}ms`)
},
}, TInterceptorPriority.AFTER_ALL)
```
## Priority levels
Interceptors run in priority order. Lower numbers run first:
| Priority | Value | Typical use |
|----------|-------|-------------|
| `BEFORE_ALL` | 0 | Help interceptor, early logging |
| `BEFORE_GUARD` | 1 | Setup before guards |
| `GUARD` | 2 | Permission checks, env validation |
| `AFTER_GUARD` | 3 | Post-auth setup |
| `INTERCEPTOR` | 4 | General-purpose (default) |
| `CATCH_ERROR` | 5 | Error formatting |
| `AFTER_ALL` | 6 | Timing, cleanup |
## Applying interceptors
| Scope | How |
|-------|-----|
| Single command | `@Intercept(fn)` on the method |
| All commands in a controller | `@Intercept(fn)` on the class |
| Every command in the app | `app.applyGlobalInterceptors(fn)` |
## What's next
- [Advanced](./advanced) — manual adapter setup, composables, DI scopes, and multi-adapter patterns
- [Interceptors guide](/moost/interceptors) — full lifecycle, class-based interceptors, and more
---
URL: "moost.org/cliapp/options.html"
LLMS_URL: "moost.org/cliapp/options.md"
---
# Options & Arguments
CLI commands usually accept more than just positional arguments. Moost provides decorators for flags (`--verbose`), short aliases (`-v`), optional parameters, and typed values.
## Positional arguments
Positional arguments come from `:param` segments in the command path. Extract them with `@Param()`:
```ts
@Cli('deploy/:env')
deploy(@Param('env') env: string) {
return `Deploying to ${env}`
}
```
```bash
my-cli deploy production # → Deploying to production
```
Add `@Description()` to document what the argument means (shown in `--help`):
```ts
@Cli('deploy/:env')
deploy(
@Description('Target environment (production, staging, dev)')
@Param('env')
env: string,
) {
return `Deploying to ${env}`
}
```
## Option flags
Use `@CliOption()` to bind a parameter to a CLI flag. Pass one or more keys — long names become `--flag`, single-character names become `-f`:
```ts
import { Cli, CliOption, Controller, Description, Param } from '@moostjs/event-cli'
@Controller()
export class DeployController {
@Cli('deploy/:env')
deploy(
@Param('env') env: string,
@Description('Project name')
@CliOption('project', 'p')
project: string,
@Description('Enable verbose logging')
@CliOption('verbose', 'v')
verbose: boolean,
) {
if (verbose) console.log(`Project: ${project}, env: ${env}`)
return `Deploying ${project} to ${env}`
}
}
```
```bash
my-cli deploy production --project my-app --verbose
my-cli deploy production -p my-app -v # same thing
```
## Boolean flags
When a parameter has type `boolean`, Moost automatically registers it as a boolean flag. This means the flag doesn't expect a value — its presence means `true`:
```ts
@Cli('build')
build(
@CliOption('watch', 'w')
watch: boolean,
@CliOption('minify', 'm')
minify: boolean,
) {
return `watch=${watch}, minify=${minify}`
}
```
```bash
my-cli build --watch --minify # → watch=true, minify=true
my-cli build -wm # → same (combined short flags)
my-cli build # → watch=undefined, minify=undefined
```
## Optional parameters
Mark a parameter as optional with `@Optional()`. Without it, a missing argument may result in `undefined`:
```ts
import { Cli, CliOption, Controller, Optional } from '@moostjs/event-cli'
@Controller()
export class BuildController {
@Cli('build')
build(
@Description('Output directory')
@CliOption('out', 'o')
@Optional()
outDir: string,
) {
return `Output: ${outDir ?? './dist'}`
}
}
```
`@Optional()` also signals intent to readers of your code and integrates with the help system.
## Sample values
Use `@Value()` to show a placeholder in help output. This doesn't set a default — it only affects `--help` display:
```ts
import { Cli, CliOption, Controller, Description } from '@moostjs/event-cli'
import { Value } from 'moost'
@Controller()
export class TestController {
@Cli('test/:config')
test(
@Description('Configuration file')
@Param('config')
config: string,
@Description('Target environment')
@CliOption('target', 't')
@Value('')
target: string,
@Description('Project name')
@CliOption('project', 'p')
@Value('')
project: string,
) {
return `Testing ${config}: ${project} in ${target}`
}
}
```
In `--help`, the options section shows `--target ` and `--project `.
## Decorator summary
| Decorator | Applies to | Purpose |
|-----------|-----------|---------|
| `@Param('name')` | parameter | Extract positional argument from `:name` in path |
| `@CliOption('key', 'k')` | parameter | Bind to `--key` / `-k` flag |
| `@Description('...')` | parameter, method | Document for help output |
| `@Optional()` | parameter | Mark as not required |
| `@Value('...')` | parameter | Show sample value in help |
## What's next
- [Controllers](./controllers) — group related commands under prefixes
- [Help System](./help) — customize auto-generated help output
---
URL: "moost.org/moost"
LLMS_URL: "moost.org/moost.md"
---
# Moost Flavors
Moost is event-agnostic by design. The core — controllers, DI, interceptors, pipes — works the same regardless of what triggered the event. On top of this core, Moost provides **adapters** for specific event domains, each wrapping the corresponding [Wooks](https://wooks.moost.org/wooks/what) adapter with a decorator layer.
## HTTP
**Package:** `@moostjs/event-http`
Build REST APIs and web servers with decorator-based routing, automatic parameter extraction, and the full Wooks HTTP composable set.
```ts
import { MoostHttp, Get, Post } from '@moostjs/event-http'
import { Moost, Controller, Param } from 'moost'
import { useBody } from '@wooksjs/event-http'
@Controller('api')
class ApiController {
@Get('hello/:name')
greet(@Param('name') name: string) {
return `Hello, ${name}!`
}
@Post('users')
async createUser() {
const { parseBody } = useBody()
const user = await parseBody<{ name: string }>()
return { created: user.name }
}
}
const app = new Moost()
const http = new MoostHttp()
app.adapter(http)
app.registerControllers(ApiController).init()
http.listen(3000)
```
Supports Express and Fastify adapters, Swagger generation, static file serving, and reverse proxy.
[Get started with HTTP →](/webapp/)
## WebSocket
**Package:** `@moostjs/event-ws`
Build real-time WebSocket servers with routed message handlers, rooms, broadcasting, and composable state. Integrates with HTTP for upgrade handling.
```ts
import { MoostHttp, Upgrade } from '@moostjs/event-http'
import { MoostWs, Message, Connect, MessageData, ConnectionId } from '@moostjs/event-ws'
import { Moost, Controller, Param } from 'moost'
import { useWsRooms } from '@wooksjs/event-ws'
@Controller('chat')
class ChatController {
@Connect()
onConnect(@ConnectionId() id: string) {
console.log(`Connected: ${id}`)
}
@Message('message', ':room')
onMessage(@Param('room') room: string, @MessageData() data: { text: string }) {
const { broadcast } = useWsRooms()
broadcast('message', { from: room, text: data.text })
}
}
const app = new Moost()
const http = new MoostHttp()
const ws = new MoostWs({ httpApp: http.getHttpApp() })
app.adapter(http).adapter(ws)
app.registerControllers(ChatController).init()
http.listen(3000)
```
Available composables: `useWsConnection()`, `useWsMessage()`, `useWsRooms()`, `useWsServer()`. HTTP composables work transparently via the upgrade request context.
[Get started with WebSocket →](/wsapp/)
## CLI
**Package:** `@moostjs/event-cli`
Build command-line applications with decorator-based command routing, typed options, and auto-generated help.
```ts
import { MoostCli, Cli, CliOption } from '@moostjs/event-cli'
import { Moost, Controller, Param } from 'moost'
@Controller()
class DeployController {
@Cli('deploy/:env')
deploy(
@Param('env') env: string,
@CliOption('verbose', 'v', { description: 'Verbose output' }) verbose: boolean,
) {
return `Deploying to ${env}${verbose ? ' (verbose)' : ''}...`
}
}
const app = new Moost()
app.adapter(new MoostCli())
app.registerControllers(DeployController).init()
```
Commands use the same route-style patterns as HTTP. Options are parsed automatically. Help output is generated from decorator metadata.
[Get started with CLI →](/cliapp/)
## Workflows
**Package:** `@moostjs/event-wf`
Build multi-step pipelines with decorator-based step definitions, state management, pause/resume support, and conditional branching.
```ts
import { MoostWf, WfStep, WfFlow, WfInput } from '@moostjs/event-wf'
import { Moost, Controller } from 'moost'
import { useWfState } from '@wooksjs/event-wf'
@Controller()
class ApprovalWorkflow {
@WfStep('review')
@WfInput('approval')
review() {
const { ctx, input } = useWfState()
ctx<{ approved: boolean }>().approved = input() ?? false
}
@WfFlow('approval-process', [
'validate',
'review',
{ condition: 'approved', steps: ['notify-success'] },
{ condition: '!approved', steps: ['notify-rejection'] },
])
approvalFlow() {}
}
```
Workflows are **interruptible** — when a step needs input, the workflow pauses and returns serializable state. Resume it later with the input, minutes or days later.
[Get started with Workflows →](/wf/)
## Multiple Adapters
Moost supports registering multiple adapters at once. Each adapter operates independently — controllers are registered once and each adapter picks up only the decorators it understands:
```ts
import { Moost, Controller, Param } from 'moost'
import { MoostHttp, Get } from '@moostjs/event-http'
import { MoostCli, Cli } from '@moostjs/event-cli'
@Controller()
class AppController {
@Get('status')
httpStatus() { return { ok: true } }
@Cli('status')
cliStatus() { return 'OK' }
}
const app = new Moost()
app.adapter(new MoostHttp()).adapter(new MoostCli())
app.registerControllers(AppController).init()
```
## Custom Adapters
You can build your own adapter for any event-driven scenario — job queues, message brokers, custom protocols. All adapters implement the `TMoostAdapter` interface and share the same controller, DI, interceptor, and pipe infrastructure.
---
URL: "moost.org/moost/controllers.html"
LLMS_URL: "moost.org/moost/controllers.md"
---
# Controllers in Moost
Controllers in Moost serve as cohesive units for organizing your event handlers (e.g., HTTP endpoints, CLI commands, or other event-driven logic). By grouping related functionality together, controllers improve the structure and maintainability of your application, making it clearer where to add new handlers or modify existing ones.
## Defining a Controller
To create a controller, annotate a class with the `@Controller()` decorator. You can optionally specify a path prefix, which applies to all handlers within that controller. This helps logically segment your API endpoints or event triggers under a common namespace.
**Example:**
```ts
import { Controller, Param } from 'moost';
@Controller('api') // All routes in this controller will start with /api
export class MainController {
// Define handlers here
}
```
## Integrating Controllers Into Your Application
There are multiple ways to bring controllers into your Moost application, accommodating different code structures and scaling strategies.
### Using `@ImportController`
If you’re extending the `Moost` class, you can import controllers by decorating your server class with `@ImportController()`:
**Example:**
```ts
import { Moost, ImportController } from 'moost';
import { MainController } from './main.controller';
@ImportController(MainController)
class MyServer extends Moost {
// MainController is now part of this server
}
```
You can also customize the prefix when importing:
```ts
@ImportController('new-prefix', MainController)
class MyServer extends Moost {
// MainController is now accessible under /new-prefix routes
}
```
For more dynamic scenarios, `@ImportController` can accept a factory function that returns an instance. This allows injecting different arguments each time:
**Example:**
```ts
import { ImportController } from 'moost';
import { DbCollection } from './controllers/DbCollection';
@ImportController(() => new DbCollection('users'))
@ImportController(() => new DbCollection('roles'))
class MyServer extends Moost {
// Two DbCollection controllers registered under their respective prefixes
}
```
### Using `registerControllers`
If you’re not extending Moost, or prefer more imperative control, you can use the `registerControllers` method directly on a Moost instance:
**Example:**
```ts
import { Moost } from 'moost';
import { MainController } from './main.controller';
const app = new Moost();
app.registerControllers(MainController);
// Now MainController is active within this app instance
```
This approach is handy for assembling controllers dynamically (e.g., conditionally adding them based on environment flags or configuration).
## Controller Scope and Lifecycles
By default, controllers are singletons — one shared instance is created and reused across all events. However, Moost allows you to create per-event instances of a controller using `@Injectable('FOR_EVENT')`.
**Example:**
```ts
import { Controller, Injectable, Param } from 'moost';
@Injectable('FOR_EVENT')
@Controller('api')
export class MainController {
@Param('name')
name!: string;
// When handling an event, a fresh instance of MainController is created.
// 'this.name' will be unique to each event.
}
```
In this scenario, each incoming event (e.g., each request) gets its own controller instance. This is especially useful for stateful logic that shouldn’t be shared between events, such as request-scoped data or temporary computations.
**Key Rules:**
- **No `FOR_EVENT` dependencies in singleton controllers:**
A singleton cannot depend on a `FOR_EVENT`-scoped dependency, since singletons are created once and cannot dynamically instantiate event-scoped instances per request.
- **Reverse is allowed:**
A `FOR_EVENT` controller can depend on singletons without issues.
*You can read more about Dependency Injection (DI) in Moost [here](/moost/di/index).*
## Structuring Your Controllers
By composing multiple controllers and leveraging prefixes, you can build a clear hierarchy of your application’s behavior. For example:
**Example:**
```ts
@Controller('api')
class ApiController {
// Handlers for /api/...
}
@Controller('admin')
class AdminController {
// Handlers for /admin/...
}
@ImportController(ApiController)
@ImportController(AdminController)
class MyServer extends Moost {
// /api/... and /admin/... endpoints are now served.
}
```
This modular approach scales well as your application grows, encouraging separation of concerns and logical grouping of related logic.
## Accessing the Controller Prefix at Runtime
Use `useControllerContext().getPrefix()` to access the controller's fully computed prefix — the accumulated path from all parent controllers and `@ImportController` overrides:
```ts
import { Controller, useControllerContext } from 'moost'
@Controller('api')
export class ApiController {
constructor() {
const { getPrefix } = useControllerContext()
console.log(getPrefix()) // '/api'
}
}
```
This works in both singleton constructors and event handlers. For singletons imported at multiple prefixes, the constructor sees the prefix from the first registration. Inside event handlers, `getPrefix()` always reflects the correct prefix for the current route.
## Best Practices
1. **Keep Controllers Focused:**
Assign each controller a clear responsibility. Avoid mixing unrelated functionality in a single controller.
2. **Use Prefixes for Clarity:**
Prefixes help distinguish sets of endpoints and can reduce conflicts or ambiguity.
3. **Leverage Scopes Wisely:**
Use `FOR_EVENT` controllers for request-scoped data, ensuring each event is isolated and won’t accidentally leak state.
4. **Combine with DI and Composables:**
Take advantage of Moost’s DI system and composables to keep controllers lean. Move complex logic into services or interceptors, and just wire them up in controllers.
---
Controllers are the backbone of your Moost application, offering a convenient way to structure your logic, manage state, and integrate with Moost’s powerful DI and event-driven architecture. By following these guidelines and examples, you can build scalable, maintainable, and testable applications.
---
URL: "moost.org/moost/di"
LLMS_URL: "moost.org/moost/di.md"
---
# Dependency Injection
Moost provides dependency injection through [`@prostojs/infact`](https://github.com/prostojs/infact). There are no modules, no providers arrays, no `forRoot()` — mark a class `@Injectable()` and Moost manages its lifecycle.
## Injectable Classes
The `@Injectable()` decorator marks a class as managed by Moost's DI system. Once marked, Moost creates and provides instances automatically when they appear as constructor parameters.
```ts
import { Injectable } from 'moost'
@Injectable()
class UserService {
findById(id: string) { /* ... */ }
}
```
By default, injectable classes are **singletons** — a single instance shared for the entire app lifetime.
## Scopes
Moost supports two scopes:
| Scope | Behavior |
|-------|----------|
| `SINGLETON` (default) | One instance for the entire app |
| `FOR_EVENT` | Fresh instance per event (HTTP request, CLI command, etc.) |
```ts
@Injectable('FOR_EVENT')
class RequestState {
// new instance for each event
}
```
::: warning Scope Rule
Do not inject `FOR_EVENT` classes into singletons. Singletons are created once, so an event-scoped dependency inside one would break event isolation. The reverse — injecting singletons into `FOR_EVENT` classes — is fine.
:::
## Controllers and DI
Controllers are automatically injectable (the `@Controller()` decorator handles this). Add constructor parameters typed as injectable classes and Moost resolves them:
```ts
import { Controller } from 'moost'
@Controller()
class UserController {
constructor(private users: UserService) {}
}
```
To make a controller event-scoped, add `@Injectable('FOR_EVENT')`:
```ts
@Injectable('FOR_EVENT')
@Controller()
class SessionController {
constructor(private state: RequestState) {}
}
```
## Integration with Pipes
When a constructor parameter or property has metadata (e.g., `@Param()`, `@Resolve()`), the [pipes pipeline](/moost/pipes/) processes the value before injection — resolving, transforming, and validating it.
---
URL: "moost.org/moost/di/circular.html"
LLMS_URL: "moost.org/moost/di/circular.md"
---
# Circular Dependencies
Circular dependencies occur when two classes depend on each other, creating a loop in the dependency graph. While refactoring to eliminate the cycle is preferred, Moost provides `@Circular()` to handle cases where it's unavoidable.
## @Circular
The `@Circular()` decorator takes a callback that returns the class constructor, deferring resolution until after both classes are defined:
```ts
import { Injectable, Circular } from 'moost'
@Injectable()
class ServiceA {
constructor(@Circular(() => ServiceB) private b: ServiceB) {}
}
@Injectable()
class ServiceB {
constructor(@Circular(() => ServiceA) private a: ServiceA) {}
}
```
The callback `() => ServiceB` breaks the reference cycle — at decoration time, `ServiceB` may not yet be defined, but by the time Moost resolves the dependency, the callback returns the actual class.
## Limitations
Dependencies involved in a circular relationship are **not fully available inside constructors**. Moost creates proxy placeholders during construction and resolves them afterward. Access circular dependencies in handler methods or lifecycle hooks, not in `constructor` bodies.
## When to Refactor Instead
`@Circular` is an escape hatch. Prefer breaking cycles by:
- **Extracting shared logic** into a third service both depend on
- **Depending on abstractions** rather than concrete classes
- **Reorganizing responsibilities** so the dependency flows one way
---
URL: "moost.org/moost/di/functional.html"
LLMS_URL: "moost.org/moost/di/functional.md"
---
# Functional Instantiation
Most dependencies are resolved via constructor injection. But sometimes you need to create an instance on demand at runtime — inside an interceptor, conditionally based on request data, or for a class that isn't part of the controller's constructor.
## useControllerContext().instantiate
The `useControllerContext()` composable provides an `instantiate` function that creates class instances through Moost's DI system, respecting scopes, provide registries, and replace registries:
```ts
import { defineBeforeInterceptor, defineAfterInterceptor, useControllerContext } from 'moost'
const startMetrics = defineBeforeInterceptor(async (reply) => {
const { instantiate } = useControllerContext()
const metrics = await instantiate(MetricsCollector)
metrics.startTimer()
})
const recordMetrics = defineAfterInterceptor(async (response, reply) => {
const { instantiate } = useControllerContext()
const metrics = await instantiate(MetricsCollector)
metrics.record()
})
```
`instantiate(ClassName)` returns a `Promise` — it resolves the class through the same DI container used for constructor injection, scoped to the current controller instance.
## What useControllerContext Returns
Beyond `instantiate`, the composable exposes the current controller's runtime context:
| Method | Returns |
|--------|---------|
| `instantiate(Class)` | DI-resolved instance of the given class |
| `getController()` | Current controller instance |
| `getMethod()` | Current handler method name |
| `getRoute()` | Full route path (prefix + handler path) |
| `getPrefix()` | Controller's computed prefix (accumulated from all parents) |
| `getControllerMeta()` | Class-level metadata |
| `getMethodMeta(name?)` | Method-level metadata |
| `getScope()` | Controller's injectable scope |
| `getParamsMeta()` | Handler parameter metadata |
## When to Use
- **Interceptors** that need a service not in the controller's constructor
- **Conditional creation** — instantiate a class only when a feature flag or request condition is met
- **Dynamic dispatch** — choose which class to instantiate based on runtime data
For most cases, constructor injection is simpler and more predictable. Use `instantiate` when static injection patterns don't fit.
---
URL: "moost.org/moost/di/provide-inject.html"
LLMS_URL: "moost.org/moost/di/provide-inject.md"
---
# Dependency Substitution
Beyond automatic constructor injection, Moost lets you control how dependencies are provided and replace one class with another — useful for testing, feature toggles, and runtime configuration.
## @Provide
`@Provide` binds a factory function to a class type or string key. The provided instance propagates down the dependency tree — the class itself and every dependency it creates (directly or transitively) will see this instance when they ask for it. A deeper `@Provide` for the same type overrides the parent one for that subtree.
This is what makes `@Provide` different from just calling `new` in a constructor: you're configuring how a dependency resolves for an entire branch of the object graph, not just one consumer.
**Class-based** — provide a specific instance for the entire subtree:
```ts
import { Provide, Controller } from 'moost'
@Provide(Logger, () => new Logger('api'))
@Controller('api')
class ApiController {
// ApiController, its dependencies, and their dependencies
// all receive this Logger instance when they inject Logger
constructor(private userService: UserService) {}
}
```
Here `UserService` and anything `UserService` depends on will all get the `Logger('api')` instance — without any of them knowing about the provide. A different controller could provide `Logger('admin')` and its subtree would get that one instead.
**String key** — differentiate multiple instances of the same type:
```ts
@Provide('primary-db', () => new Database(primaryConfig))
@Provide('analytics-db', () => new Database(analyticsConfig))
class ReportController {
constructor(
@Inject('primary-db') private primary: Database,
@Inject('analytics-db') private analytics: Database,
) {}
}
```
## @Inject
Explicitly specify which dependency to inject by class type or string key:
```ts
import { Inject } from 'moost'
class MyController {
constructor(@Inject('cache-store') private cache: CacheStore) {}
}
```
Use `@Inject` when the constructor parameter type alone isn't enough to identify the dependency — typically with string-keyed provides.
## Global Provide Registry
Instead of scattering `@Provide` across classes, centralize dependency definitions on the app instance:
```ts
import { createProvideRegistry } from 'moost'
app.setProvideRegistry(createProvideRegistry(
[DatabaseConnection, () => new DatabaseConnection(config)],
['cache-store', () => new RedisCache()],
))
```
This keeps configuration in one place, making it easier to swap implementations across the whole app.
## @Replace
Override one class with another globally. Every injection point expecting the original class receives the replacement instead.
**Via decorator:**
```ts
import { Replace } from 'moost'
@Replace(EmailService, MockEmailService)
class TestController {
// EmailService injections now resolve to MockEmailService
}
```
**Via registry:**
```ts
import { createReplaceRegistry } from 'moost'
app.setReplaceRegistry(createReplaceRegistry(
[EmailService, MockEmailService],
[PaymentGateway, TestPaymentGateway],
))
```
This is especially useful for:
- **Testing** — inject mocks without changing consumer code
- **Feature toggles** — swap implementations at startup
- **Third-party controllers** — override dependencies you can't modify directly
## Custom Scopes
Beyond the built-in `SINGLETON` and `FOR_EVENT` scopes, you can define custom named scopes with associated variables using `defineInfactScope`. Classes injected via `@InjectFromScope` are instantiated within that scope, and `@InjectScopeVars` lets them access the scope's variables.
### Defining a Scope
Register a named scope with its variables before the app starts:
```ts
import { defineInfactScope } from 'moost'
defineInfactScope('tenant', {
tenantId: 'acme',
region: 'eu-west-1',
})
```
### @InjectFromScope
Marks a constructor parameter or property so that the injected class is instantiated **within the named scope**:
```ts
import { Injectable, InjectFromScope } from 'moost'
@Injectable()
class TenantConfig {
constructor(/* resolved within the 'tenant' scope */) {}
}
@Controller()
class BillingController {
constructor(@InjectFromScope('tenant') private config: TenantConfig) {}
}
```
### @InjectScopeVars
Resolves scope variables for classes living inside a custom scope. Use it inside classes that are instantiated via `@InjectFromScope`:
```ts
import { Injectable, InjectScopeVars } from 'moost'
@Injectable()
class TenantService {
constructor(
// inject a single variable by name
@InjectScopeVars('tenantId') private tenantId: string,
) {}
}
```
Omit the name to receive the entire scope variables object:
```ts
@Injectable()
class TenantService {
constructor(
@InjectScopeVars() private vars: { tenantId: string; region: string },
) {}
}
```
---
URL: "moost.org/moost/event-lifecycle.html"
LLMS_URL: "moost.org/moost/event-lifecycle.md"
---
# Moost Event Lifecycle
When Moost receives an event (e.g. an HTTP request, a CLI command), it orchestrates a series of steps to route the event, initialize the necessary state, invoke the appropriate handler, and process interceptors and responses. Understanding this lifecycle helps developers anticipate where to integrate their logic — such as validation, authentication, logging, or error handling — and how to best leverage Moost’s composable and extensible architecture.
## Diagram
Below is a representative diagram illustrating the lifecycle of a Moost event.
## Detailed Steps
1. **Incoming Event**
The lifecycle begins when Moost receives an event. This might be an HTTP request, a CLI command, or another type of event. At this point, Moost prepares to handle it by creating a fresh, isolated context.
2. **Create Event Context**
Wooks uses `createEventContext` ([What is Wooks?](https://wooks.moost.org/wooks/what)) to initialize a per-event context that persists across asynchronous operations. This ensures each event has its own storage for data, dependencies, and state.
3. **Perform Routing**
The event is passed through the router (e.g., `router.lookup`) to find a matching handler. If a route matches, Wooks identifies which [controller](/moost/controllers) and handler method will process this event. If not found, a "Not Found" error is raised (Step 10).
4. **Set Controller Instance in Event Context**
With a route found, Moost either creates or retrieves an appropriate [controller](/moost/controllers) instance. It attaches this [controller](/moost/controllers) to the event context along with the full route path and the controller's computed prefix, making them accessible throughout the event lifecycle via [`useControllerContext`](/moost/meta/controller) composable (`getRoute()`, `getPrefix()`).
5. **Run Interceptors ‘before’ Hooks**
Moost resolves all applicable [interceptors](/moost/interceptors) and runs their "before" hooks. Class-based interceptors are resolved through DI at this stage. Before hooks run before handler arguments are resolved, and can perform authentication checks, request transformations, or short-circuit the request entirely by calling `reply(value)`. If an interceptor short-circuits, Moost skips to the after/error phase (Step 8). If an error occurs, Moost moves to error handling (Step 10).
6. **Resolve Handler Arguments**
The handler’s parameters are resolved using [pipelines](/moost/pipes/index). This includes [resolve pipe](/moost/pipes/resolve), [validation pipe](/moost/pipes/validate) (optional) and [custom pipes](/moost/pipes/custom) (optional). If something goes wrong (e.g., invalid data), Moost proceeds to error handling (Step 10).
7. **Call Handler Method With Resolved Arguments**
The handler method is invoked. It receives fully prepared arguments, so the logic within the handler can focus purely on business logic. If the handler throws an error, Moost transitions to the ‘error’ hooks of interceptors (Step 9). If an error occurs beforehand, Step 10 is triggered.
8. **Run Interceptors ‘after’ Hooks**
If the handler succeeded, Moost executes the "after" hooks of [interceptors](/moost/interceptors). This could involve adding headers, logging success, or modifying the response. Errors here also lead to Step 10.
9. **Run Interceptors ‘error’ Hooks**
If the handler failed, [interceptors’](/moost/interceptors) "error" hooks allow transforming or logging the error, and possibly returning an alternate response. If an error arises in the error hooks, Moost moves to Step 10.
10. **Raise Error**
If any previous step encounters an error, Moost halts the normal flow and produces an error response. The exact nature of the error response depends on the event type and any interceptors or error handlers that may have modified the error along the way.
11. **Process Final Response**
Wooks then formats the final response based on the event type (e.g., JSON response for HTTP, textual output for CLI). If this step fails, an error response is raised (Step 10). ([What is Wooks?](https://wooks.moost.org/wooks/what))
12. **Return Response to Event Origin**
Finally, the processed (or error) response is returned to the event’s origin, concluding the event lifecycle.
## Summary
Whether you’re validating input data, enforcing security policies, logging metrics, or formatting output, understanding and leveraging these lifecycle stages ensures that your Moost application remains maintainable, testable, and adaptable to evolving requirements.
---
URL: "moost.org/moost/interceptors.html"
LLMS_URL: "moost.org/moost/interceptors.md"
---
# Interceptors in Moost
Interceptors hook into the event lifecycle to run logic before and after your handlers. They handle cross-cutting concerns — authentication, logging, error handling, response transformation — without cluttering controller code.
## Key Concepts
- **Lifecycle Hooks:** Interceptors run at defined points:
- **Before:** Runs before the handler. Can short-circuit execution via `reply(value)`.
- **After:** Runs after successful handler execution. Can transform the response.
- **Error:** Runs when the handler throws. Can recover with a replacement response.
::: tip
*Check the [Event Lifecycle Diagram](/moost/event-lifecycle#diagram) to see exactly when interceptors execute.*
:::
- **Priority Levels:**
Interceptors execute in order based on their priority (`TInterceptorPriority`):
| Priority | Value | Use case |
|---|---|---|
| `BEFORE_ALL` | 0 | Setup, timing, context |
| `BEFORE_GUARD` | 1 | Pre-auth logic |
| `GUARD` | 2 | Authentication & authorization |
| `AFTER_GUARD` | 3 | Post-auth setup |
| `INTERCEPTOR` | 4 | General purpose (default) |
| `CATCH_ERROR` | 5 | Error formatting |
| `AFTER_ALL` | 6 | Cleanup, final headers |
## Functional Interceptors
Use the `define*` helpers to create interceptors as plain objects.
### Before interceptor
Runs before the handler. Call `reply(value)` to skip the handler entirely:
```ts
import { defineBeforeInterceptor, TInterceptorPriority } from 'moost'
const authGuard = defineBeforeInterceptor((reply) => {
if (!isAuthenticated()) {
reply(new HttpError(401))
}
}, TInterceptorPriority.GUARD)
```
### After interceptor
Runs after the handler succeeds. Receives the response; call `reply(newValue)` to replace it:
```ts
import { defineAfterInterceptor, TInterceptorPriority } from 'moost'
const wrapper = defineAfterInterceptor((response, reply) => {
reply({ data: response, timestamp: Date.now() })
}, TInterceptorPriority.AFTER_ALL)
```
### Error interceptor
Runs when the handler throws. Receives the error; call `reply(value)` to recover:
```ts
import { defineErrorInterceptor, TInterceptorPriority } from 'moost'
const errorFormatter = defineErrorInterceptor((error, reply) => {
reply({
error: error.message,
code: error.statusCode || 500,
})
}, TInterceptorPriority.CATCH_ERROR)
```
### Full interceptor
Combine multiple hooks in a single definition:
```ts
import { defineInterceptor, TInterceptorPriority } from 'moost'
const timing = defineInterceptor({
before() {
// record start time
},
after(response, reply) {
// log duration, transform response
},
error(error, reply) {
// handle errors
},
}, TInterceptorPriority.INTERCEPTOR)
```
### Default priorities
Each helper uses a sensible default priority:
| Helper | Default priority |
|---|---|
| `defineBeforeInterceptor` | `INTERCEPTOR` |
| `defineAfterInterceptor` | `AFTER_ALL` |
| `defineErrorInterceptor` | `CATCH_ERROR` |
| `defineInterceptor` | `INTERCEPTOR` |
## Class-Based Interceptors
When you need dependency injection, define interceptors as classes using the `@Interceptor` decorator with `@Before`, `@After`, and `@OnError` method decorators.
### Basic example
```ts
import { Interceptor, Before, TInterceptorPriority } from 'moost'
@Interceptor(TInterceptorPriority.GUARD)
class AuthInterceptor {
constructor(private authService: AuthService) {}
@Before()
check() {
if (!this.authService.isAuthenticated()) {
throw new HttpError(401)
}
}
}
```
Moost creates the interceptor through DI, so `AuthService` is injected automatically.
### Using `@Overtake` and `@Response`
Two parameter decorators let interceptor methods access the reply function and handler result:
- **`@Overtake()`** — injects the reply function. Call it to short-circuit or replace the response.
- **`@Response()`** — injects the handler result (in `@After`) or the error (in `@OnError`).
```ts
import {
Interceptor, Before, After, OnError,
Overtake, Response, TInterceptorPriority,
} from 'moost'
import type { TOvertakeFn } from 'moost'
@Interceptor(TInterceptorPriority.INTERCEPTOR)
class ResponseTransformer {
@After()
transform(@Response() response: unknown, @Overtake() reply: TOvertakeFn) {
reply({ data: response, timestamp: Date.now() })
}
}
@Interceptor(TInterceptorPriority.CATCH_ERROR)
class ErrorHandler {
@OnError()
handle(@Response() error: Error, @Overtake() reply: TOvertakeFn) {
reply({ message: error.message })
}
}
```
### DI scopes
By default, `@Interceptor` makes the class injectable as a singleton. Pass a scope to change this:
```ts
@Interceptor(TInterceptorPriority.INTERCEPTOR, 'FOR_EVENT')
class RequestScopedInterceptor {
// New instance per event — can inject request-scoped dependencies
}
```
## Applying Interceptors
### Per handler
```ts
import { Intercept, Controller } from 'moost'
@Controller()
export class ExampleController {
@Get('secret')
@Intercept(authGuard)
secret() { /* protected */ }
@Get('public')
publicRoute() { /* not protected */ }
}
```
### Per controller
All handlers in the controller are intercepted:
```ts
@Intercept(authGuard)
@Controller('admin')
export class AdminController {
@Get('dashboard')
dashboard() { /* protected */ }
@Get('settings')
settings() { /* also protected */ }
}
```
### Globally
Affects every handler in the application:
```ts
const app = new Moost()
app.applyGlobalInterceptors(timing, errorFormatter)
```
Class-based interceptors work the same way:
```ts
app.applyGlobalInterceptors(AuthInterceptor)
```
## Turning Interceptors into Decorators
Wrap an interceptor with `Intercept` to create a reusable decorator:
```ts
import { Intercept, defineBeforeInterceptor, TInterceptorPriority } from 'moost'
const guardFn = defineBeforeInterceptor((reply) => {
if (!isAuthorized()) {
throw new HttpError(401)
}
}, TInterceptorPriority.GUARD)
// Create a decorator
const RequireAuth = Intercept(guardFn)
// Use it
@RequireAuth
@Controller('admin')
export class AdminController { /* ... */ }
```
### Parameterized decorators
Create decorator factories for configurable interceptors:
```ts
const RequireRole = (role: string) => {
const fn = defineBeforeInterceptor((reply) => {
if (!hasRole(role)) {
throw new HttpError(403, `Requires role: ${role}`)
}
}, TInterceptorPriority.GUARD)
return Intercept(fn)
}
@RequireRole('admin')
@Controller('admin')
export class AdminController { /* ... */ }
```
## Practical Examples
### Request logger
```ts
import { defineInterceptor, TInterceptorPriority } from 'moost'
import { useRequest } from '@wooksjs/event-http'
import { useLogger } from '@wooksjs/event-core'
export const requestLogger = defineInterceptor({
after() {
const { url, method } = useRequest()
const logger = useLogger('http')
logger.log(`${method} ${url} OK`)
},
error(error) {
const { url, method } = useRequest()
const logger = useLogger('http')
logger.error(`${method} ${url} FAILED: ${error.message}`)
},
}, TInterceptorPriority.BEFORE_ALL)
```
### Error handler
```ts
import { defineErrorInterceptor, TInterceptorPriority } from 'moost'
import { useResponse } from '@wooksjs/event-http'
export const errorHandler = defineErrorInterceptor((error, reply) => {
const response = useResponse()
const status = error.statusCode || 500
response.status = status
reply({
error: error.message,
statusCode: status,
})
}, TInterceptorPriority.CATCH_ERROR)
```
### Timing interceptor (class-based)
```ts
import { Interceptor, Before, After, TInterceptorPriority } from 'moost'
@Interceptor(TInterceptorPriority.BEFORE_ALL, 'FOR_EVENT')
class TimingInterceptor {
private start = 0
@Before()
recordStart() {
this.start = performance.now()
}
@After()
logDuration() {
const ms = (performance.now() - this.start).toFixed(1)
console.log(`Request completed in ${ms}ms`)
}
}
```
## Best Practices
- **Keep interceptors focused:** Each interceptor should handle a single concern (auth, logging, formatting). This keeps them composable and testable.
- **Use the right priority:** Assign meaningful priorities so interceptors execute in a logical order — auth before business logic, cleanup after everything else.
- **Prefer functional for simple cases:** `defineBeforeInterceptor` / `defineAfterInterceptor` are lighter than class-based interceptors and easier to compose.
- **Use class-based for DI:** When you need injected services, use `@Interceptor` with method decorators.
---
URL: "moost.org/moost/logging.html"
LLMS_URL: "moost.org/moost/logging.md"
---
# Logging in Moost
Moost inherits a logging mechanism from Wooks based on the
[@prostojs/logger](https://github.com/prostojs/logger/) npm package, which
provides a convenient way to manage and track log messages.
## Logger Options
The following options can be configured for the logger:
- `topic` (optional): Specifies a topic or category for log messages to help
organize and categorize the logs based on different topics.
- `persistLevel` (optional): Defines the maximum log level that will be
persisted in the logger instance. Messages above this level will not be
persisted.
- `level` (optional): Filters log messages based on the specified log level.
Only log messages at or below this level will be processed and sent to
transports.
- `transports` (optional): An array of functions that handle log messages.
Transports can be used to send log messages to various destinations, such as
the console, files, or external APIs.
- `mapper` (optional): A function that maps log messages to a desired format,
allowing customization of the structure or content of the log messages.
- `levels` (optional): A list of log level names. By default, the levels include
`fatal`, `error`, `warn`, `log`, `info`, `debug`, and `trace`. This list can
be customized to meet specific logging requirements.
# Logger and EventLogger
In Wooks, you have two options for configuring logging: `logger` and `eventLogger`.
- `logger`: This option allows you to define a single logger for the entire Wooks instance.
It provides a unified logging experience across all events and commands.
- `eventLogger`: With the eventLogger option, you can customize the logging behavior for each event in Wooks.
Each event will have its own logger instance, identified by an `eventId`.
This allows you to have granular control over event-specific logging and even persist log messages during the execution of an event using the `persistLevel` option.
## Usage
To create a Moost application with customized logging configuration, you can
provide the `eventOptions` object when initializing your Moost instance. This
can be achieved using the `createMoostApp` function.
Here's an example that demonstrates the creation of a Moost HTTP adapter with
logging configuration:
```ts
import { MoostHttp } from '@moostjs/event-http';
const httpAdapter = new MoostHttp({
logger: {
topic: "my-moost-app",
level: 2, // Allow only fatal and error logs
transports: [
(log) =>
console.log(
`[${log.topic}][${log.type}] ${log.timestamp}`,
...log.messages,
),
],
},
eventOptions: {
eventLogger: {
level: 5, // Allow fatal, error, warn, log, info, and debug logs
persistLevel: 3, // Persist only fatal, error, and warn logs
},
},
});
```
The example above demonstrates the configuration of a Moost HTTP adapter. The
`logger` option is used to define the logging behavior for the entire Moost
instance. It specifies the topic, log level, and transports for the logger. In
this case, log messages at the `fatal` and `error` levels are allowed, and the
log messages are sent to the console.
To use the event logger within a controller, you can inject it using the
`@InjectEventLogger` decorator. Here's an example:
```ts
import { Get } from '@moostjs/event-http';
import type { Logger } from 'moost'
import { Controller, InjectEventLogger } from 'moost'
@Controller()
class MyController {
@Get('endpoint')
handleRequest(@InjectEventLogger() logger: Logger) {
// Controller logic for handling the request
logger.log('...')
}
}
```
In the example above, the `MyController` class
has an event handler method `handleRequest`, which receives the event logger
instance injected using the `@InjectEventLogger` decorator. The event logger can
be used to log messages within the event handler.
## @InjectMoostLogger
While `@InjectEventLogger` provides event-scoped loggers from the Wooks context, `@InjectMoostLogger` resolves the **app-level** logger from the Moost instance. This is useful when you need a logger that lives outside the event lifecycle — for example, in singleton services that log during initialization or background work.
```ts
import { Controller, InjectMoostLogger } from 'moost'
import type { ProstoLogger } from 'moost'
@Controller()
class NotificationService {
constructor(@InjectMoostLogger('notifications') private logger: ProstoLogger) {
this.logger.log('NotificationService initialized')
}
}
```
The optional `topic` argument sets the logger's topic (banner). If omitted, the topic falls back to the class-level `@LoggerTopic` value, or the class `@Id` metadata.
### @LoggerTopic
`@LoggerTopic` sets a default logger topic at the class level, used by `@InjectMoostLogger` when no explicit topic is passed:
```ts
import { Controller, InjectMoostLogger, LoggerTopic } from 'moost'
import type { ProstoLogger } from 'moost'
@LoggerTopic('mailer')
@Controller()
class MailerController {
constructor(@InjectMoostLogger() private logger: ProstoLogger) {
// logger topic is 'mailer'
}
}
```
An explicit topic in `@InjectMoostLogger('override')` takes precedence over the class-level `@LoggerTopic`.
For more details on logging in Moost, please refer to the
[Logging in Wooks](https://wooks.moost.org/wooks/advanced/logging.html) page as
Moost logging is based on the Wooks logging mechanism.
---
URL: "moost.org/moost/meta"
LLMS_URL: "moost.org/moost/meta.md"
---
# Introduction to Metadata in Moost
Metadata is a foundational concept in Moost, enabling a declarative and extensible approach to configuring and enhancing your application’s behavior. Powered by the [`@prostojs/mate`](https://github.com/prostojs/mate) library, Moost’s metadata system allows developers to attach additional attributes to classes, methods, properties, and parameters through decorators. These attributes can be accessed and manipulated at runtime, facilitating a wide range of functionalities from routing and dependency injection to validation and interception.
## What Is Metadata in Moost?
In Moost, metadata serves as a mechanism to enrich your code elements with supplementary information without cluttering your business logic. By using decorators, you can declaratively specify configurations, behaviors, and constraints directly within your codebase. This approach promotes cleaner, more maintainable, and highly customizable server-side applications.
## Core Capabilities
- **Decorator-Driven Configuration:**
Attach metadata to classes, methods, properties, and parameters using decorators like `@Controller`, `@Get`, `@Injectable`, and more.
- **Runtime Accessibility:**
Access and manipulate metadata during runtime to dynamically influence application behavior based on the attached attributes.
- **Extensibility:**
Extend Moost’s functionality by creating custom decorators and metadata, tailored to your specific application needs.
- **Metadata Inheritance:**
Support for inheriting metadata from superclass to subclass, allowing for reusable and hierarchical configurations.
## Key Use Cases
- **Routing:**
Define HTTP routes and associate them with handler methods using decorators such as `@Get`, `@Post`, etc.
- **Dependency Injection (DI):**
Manage dependencies seamlessly with decorators like `@Injectable` and `@Inject`, enabling easy injection of services and repositories.
- **Validation:**
Attach validation schemas to Data Transfer Objects (DTOs) using decorators such as `@UseValidatorPipe` from `@atscript/moost-validator`, ensuring data integrity before it reaches your handlers.
- **Interceptors:**
Apply cross-cutting concerns such as logging, authentication, or transformation logic around your handlers using decorators like `@Interceptor`.
- **General-Purpose Metadata:**
Use decorators like `@Id`, `@Label`, and `@Description` to add descriptive metadata to various code elements, enhancing documentation and tooling support.
## Customization and Extensibility
Moost’s metadata system is highly customizable, allowing advanced users to extend its capabilities through custom metadata. This is particularly useful when building additional modules or integrating with other libraries. By defining your own metadata interfaces and decorators, you can tailor Moost to fit unique application requirements.
For more details on customizing metadata, refer to the [Customizing Metadata](/moost/meta/custom) and [General-Purpose Metadata](/moost/meta/common) documentation pages.
## Metadata Inheritance
Moost supports metadata inheritance, enabling subclasses to inherit metadata from their superclasses. This feature promotes reusability and consistency across your application’s components. By using decorator `@Inherit`, you can ensure that common metadata attributes are propagated through your class hierarchies.
Learn more about metadata inheritance in the [Metadata Inheritance](/moost/meta/inherit) guide.
## Summary
Moost’s metadata system, powered by [`@prostojs/mate`](https://github.com/prostojs/mate), provides a flexible and powerful way to annotate and configure your application components. By leveraging decorators to attach metadata, you can create cleaner, more maintainable, and highly customizable server-side applications. Whether you’re utilizing built-in decorators for routing, DI, validation, and interception, or crafting your own custom decorators to extend Moost’s functionality, the metadata system is integral to building robust and scalable applications with Moost.
For deeper insights and practical guides, explore the following documentation pages:
- [General-Purpose Metadata](/moost/meta/common)
- [Customizing Metadata](/moost/meta/custom)
- [Metadata Inheritance](/moost/meta/inherit)
---
URL: "moost.org/moost/meta/common.html"
LLMS_URL: "moost.org/moost/meta/common.md"
---
# General-Purpose Metadata
Moost provides a set of **general-purpose metadata fields** designed to be reusable across various functionalities within your application. These shared metadata fields help avoid redundancy by offering common attributes that different modules and components can leverage, ensuring consistency and reducing the need to define similar metadata in multiple places.
## Why General-Purpose Metadata?
When developing complex applications, it's common to encounter scenarios where multiple functionalities require similar metadata attributes. For example:
- **Labels and Descriptions:** Various modules like routing, validation, and documentation (e.g., Swagger) may need descriptive information about classes, methods, or parameters.
- **Validation Requirements:** Different parts of the application might need to specify whether certain fields are required or optional.
To streamline this process, Moost introduces general-purpose metadata fields that can be applied universally across different modules. This approach ensures that:
- **Consistency:** Shared metadata fields maintain uniformity across various functionalities.
- **Reusability:** Avoids the need to redefine similar metadata attributes in different modules.
- **Flexibility:** Allows multiple functionalities to utilize the same metadata for diverse purposes without conflict.
**Example Use Case:**
An automatic Swagger generator can utilize the `description` metadata field to populate endpoint descriptions in the Swagger UI, while the same `description` can be used by another module for logging or documentation purposes.
## Common Fields
Moost offers the following general-purpose metadata fields, each associated with a specific decorator:
| **Field** | **Decorator** | **Description** |
|----------------|--------------------|------------------------------------------------------------------------------------------|
| `id` | `@Id` | Assigns a unique identifier to a class, method, parameter, or other relevant component. |
| `label` | `@Label` | Provides a descriptive label for a class, method, parameter, or other components. |
| `value` | `@Value` | Sets a default value that can be utilized in various scenarios. |
| `description` | `@Description` | Offers a detailed description for a class, method, or other components. |
| `optional` | `@Optional` | Indicates whether a field is optional. |
| `required` | `@Required` | Specifies that a field is mandatory. |
## How to Use General-Purpose Metadata
These decorators can be applied to **classes**, **methods**, **parameters**, or **properties** to attach the corresponding metadata. This metadata can then be accessed and utilized by different modules or functionalities within Moost.
### Example: Annotating a Controller
```ts
import { Controller, Get, Id, Label, Description, Param, Required } from 'moost';
@Controller('api/users')
@Id('UserController')
@Label('User Management Controller')
@Description('Handles all user-related operations')
export class UserController {
@Get(':id')
@Id('GetUserById')
@Description('Retrieves a user by their unique ID')
getUser(
@Param('id')
@Required()
userId: string
) {
// Handler logic
return `User ID: ${userId}`;
}
}
```
**Explanation:**
- **Class Decorators:**
- `@Id('UserController')`: Assigns a unique ID to the `UserController` class.
- `@Label('User Management Controller')`: Provides a descriptive label.
- `@Description('Handles all user-related operations')`: Adds a detailed description.
- **Method Decorators:**
- `@Get(':id')`: Defines an HTTP GET route.
- `@Id('GetUserById')`: Assigns a unique ID to the `getUser` method.
- `@Description('Retrieves a user by their unique ID')`: Describes the method's functionality.
- **Parameter Decorators:**
- `@Param('id')`: Binds the `id` route parameter to the `userId` argument.
- `@Required()`: Marks the `userId` parameter as mandatory.
This setup allows different modules, such as routing and documentation generators, to access and utilize the same metadata (`description`, `label`, etc.) without duplicating definitions.
## Benefits of Using General-Purpose Metadata
- **Avoid Redundancy:** Define common attributes once and reuse them across multiple modules and components.
- **Enhance Consistency:** Maintain uniform metadata standards throughout your application, simplifying maintenance and onboarding.
- **Improve Flexibility:** Allow different functionalities to interpret and use the same metadata according to their specific needs.
- **Facilitate Integration:** Enable seamless integration with external tools (e.g., Swagger for API documentation) by providing standardized metadata.
## Further Reading
To explore more about how to effectively use and extend metadata in Moost, refer to the following documentation pages:
- [Customizing Metadata](/moost/meta/custom)
- [Metadata Inheritance](/moost/meta/inherit)
These guides provide in-depth instructions on creating custom metadata, applying it to your components, and leveraging inheritance to promote reusability and consistency.
---
URL: "moost.org/moost/meta/controller.html"
LLMS_URL: "moost.org/moost/meta/controller.md"
---
# Controller Metadata
The **Controller Metadata** is available through the `useControllerContext` composable and provides developers with a powerful and convenient way to access and interact with metadata related to the current controller and its methods during event processing. This composable is essential for advanced use cases such as implementing custom interceptors, performing dynamic logic based on metadata, and enhancing the flexibility of your application’s event handling mechanisms.
[[toc]]
## Purpose
The `useControllerContext` composable serves to:
- **Access Controller Metadata:** Retrieve metadata associated with the current controller class.
- **Access Method Metadata:** Obtain metadata for the specific method handling an event.
- **Access Property Metadata:** Fetch metadata for individual properties within the controller.
- **Understand Parameter Metadata:** Gain insights into the metadata of method parameters.
- **Determine Execution Context:** Identify the current method’s name and the controller’s scope.
## Available Methods
The `useControllerContext` composable provides several methods to access different aspects of controller metadata:
### `getPrefix(): string | undefined`
**Description:**
Returns the controller's fully computed prefix — the accumulated path from all parent controllers and `@ImportController` overrides. Available in the constructor of singleton controllers and inside event handlers.
**Parameters:**
None
**Returns:**
The computed prefix string (e.g., `'/api/v2'`) or `undefined` if not set.
**Usage Example:**
```ts
const { getPrefix } = useControllerContext()
console.log(getPrefix()) // e.g., '/api/v2'
```
::: tip
For singleton controllers imported at multiple prefixes, `getPrefix()` in the constructor reflects the prefix from the first registration only. Inside event handlers, it always reflects the correct prefix for the current route.
:::
### `getRoute(): string | undefined`
**Description:**
Returns the full route path for the current handler, including the controller prefix and the handler's own path segment.
**Parameters:**
None
**Returns:**
The full route string (e.g., `'/api/v2/users'`) or `undefined` if not set.
**Usage Example:**
```ts
const { getRoute } = useControllerContext()
console.log(getRoute()) // e.g., '/api/v2/users'
```
### `getPropMeta(propName: string | symbol): TMoostMetadata | undefined`
**Description:**
Retrieves the metadata associated with a specific property of the current controller.
**Parameters:**
- `propName`: The name of the property whose metadata you want to access.
**Returns:**
The metadata object for the specified property or `undefined` if not found.
**Usage Example:**
```ts
const propMeta = getPropMeta('username');
console.log(propMeta?.description); // Outputs the description metadata of the 'username' property
```
### `getControllerMeta(): TMoostMetadata | undefined`
**Description:**
Fetches the metadata of the current controller class.
**Parameters:**
None
**Returns:**
The metadata object of the controller or `undefined` if not found.
**Usage Example:**
```ts
const controllerMeta = getControllerMeta();
console.log(controllerMeta?.label); // Outputs the label metadata of the controller
```
### `getMethodMeta(): TMoostMetadata | undefined`
**Description:**
Obtains the metadata of the current event handler method.
**Parameters:**
None
**Returns:**
The metadata object of the method or `undefined` if not found.
**Usage Example:**
```ts
const methodMeta = getMethodMeta();
console.log(methodMeta?.id); // Outputs the ID metadata of the method
```
### `getParamsMeta(): TMoostMetadata[] | undefined`
**Description:**
Retrieves an array of metadata objects for each parameter of the current event handler method.
**Parameters:**
None
**Returns:**
An array of metadata objects for the parameters or `undefined` if not found.
**Usage Example:**
```ts
const paramsMeta = getParamsMeta();
paramsMeta?.forEach((meta, index) => {
console.log(`Parameter ${index} description: ${meta.description}`);
});
```
### `getMethod(): string | undefined`
**Description:**
Gets the name of the current event handler method.
**Parameters:**
None
**Returns:**
The method name as a string or `undefined` if not found.
**Usage Example:**
```ts
const methodName = getMethod();
console.log(`Current method: ${methodName}`);
```
### `getScope(): 'SINGLETON' | 'FOR_EVENT' | undefined`
**Description:**
Determines the scope of the current controller, indicating whether it is a singleton or scoped per event.
**Parameters:**
None
**Returns:**
The scope of the controller (`'SINGLETON'` or `'FOR_EVENT'`) or `undefined` if not set.
**Usage Example:**
```ts
const scope = getScope();
console.log(`Controller scope: ${scope}`);
```
---
URL: "moost.org/moost/meta/custom.html"
LLMS_URL: "moost.org/moost/meta/custom.md"
---
# Customizing Metadata in Moost
This guide provides an overview of how to customize metadata in Moost, enabling advanced users to extend the framework’s capabilities to suit specific application needs.
## Extending Metadata
### 1. Define a Custom Metadata Interface
Start by defining an interface that represents your custom metadata attributes. This interface should include only the properties relevant to your specific functionality.
```ts
// custom-metadata.ts
export interface TCustomMeta {
casePipeOption?: "lower" | "upper" | "camel" | "pascal";
// Add more custom properties as needed
}
```
::: warning
**Ensure that all custom metadata property names are unique and descriptive** to prevent collisions with Moost’s native metadata or any third-party metadata extensions. For example, use `casePipeOption` instead of generic names like `option` to maintain clarity and avoid unintended behavior.
:::
## Accessing the Mate Instance
Moost provides the `getMoostMate` function to access the Mate instance, which manages metadata. This function accepts three generic type parameters corresponding to different levels of metadata: Class, Property, and Parameter.
### Function Signature
```ts
function getMoostMate<
Class extends TObject = TEmpty,
Prop extends TObject = TEmpty,
Param extends TObject = TEmpty,
>(): Mate<
TMoostMetadata & Class & { params: Array },
TMoostMetadata & Prop & { params: Array }
>
```
### Explanation of Generic Types
- **Class (`Class`)**: Defines metadata attributes applicable at the class level.
- **Property (`Prop`)**: Defines metadata attributes applicable to properties.
- **Parameter (`Param`)**: Defines metadata attributes applicable to parameters.
By specifying these generics, you ensure type safety and IntelliSense support when working with custom metadata.
## Creating Custom Decorators
Custom decorators allow you to attach your defined metadata to various targets within your application. Utilize the `mate.decorate` method to define these decorators.
### Example: Case Transformation Decorators
```ts
// custom-decorators.ts
import { getMoostMate } from 'moost';
import type { TCustomMeta } from './custom-metadata';
// Retrieve the custom Mate instance
const mate = getMoostMate();
/**
* Decorator to set the case transformation option to uppercase
*/
export const Uppercase = () => mate.decorate('casePipeOption', 'upper');
/**
* Decorator to set the case transformation option to camelCase
*/
export const Camelcase = () => mate.decorate('casePipeOption', 'camel');
// Add more decorators as needed
```
**Explanation:**
- **`Uppercase` Decorator:** Attaches the `casePipeOption` metadata with the value `'upper'` to the target.
- **`Camelcase` Decorator:** Attaches the `casePipeOption` metadata with the value `'camel'` to the target.
## Reading Custom Metadata
Accessing the attached metadata at runtime allows your application to make decisions based on the annotations. Use the `mate.read` method to retrieve metadata for classes, methods, properties, or parameters.
::: tip
In many cases you can call `useControllerContext` composable which provides a way to get metadata for current controller during event processing. But sometimes it's usefull to access mate instance directly (if you are not in event context). Such case is covered below.
You can read more about Controller Context composable [here](/moost/meta/controller).
:::
### Example: Reading Metadata from a Class
```ts
// read-metadata.ts
import { getMoostMate } from 'moost';
import { CustomController } from './controllers/custom.controller';
import type { TCustomMeta } from './custom-metadata';
// Retrieve the custom Mate instance
const mate = getMoostMate();
// Read metadata from the CustomController class
const classMeta = mate.read(CustomController);
console.log(classMeta?.casePipeOption); // Output: undefined (if not set at class level)
```
### Example: Reading Metadata from a Method
```ts
// read-method-metadata.ts
import { getMoostMate } from 'moost';
import { CustomController } from './controllers/custom.controller';
import type { TCustomMeta } from './custom-metadata';
// Retrieve the custom Mate instance
const mate = getMoostMate();
// Read metadata from the getUser method
const methodMeta = mate.read(CustomController, 'getUser');
console.log(methodMeta?.casePipeOption); // Output: undefined (if not set at method level)
```
### Example: Reading Metadata from a Method Parameter
When reading metadata for method parameters, Moost aggregates all parameter metadata into a `params` array within the method's metadata. Each entry in the `params` array corresponds to a parameter, indexed by their position.
```ts
// read-param-metadata.ts
import { getMoostMate } from 'moost';
import { CustomController } from './controllers/custom.controller';
import type { TCustomMeta } from './custom-metadata';
// Retrieve the custom Mate instance
const mate = getMoostMate();
// Read metadata from the getUser method
const methodMeta = mate.read(CustomController.prototype, 'getUser');
// Access metadata for the first parameter (index 0)
const paramMeta = methodMeta?.params[0];
console.log(paramMeta?.casePipeOption); // Output: 'upper' or 'camel', based on decorator
```
**Note:** `'design:paramtypes'` metadata is already provided via `params` array by `@prostojs/mate`.
## Mate API Reference
The Mate instance provides several methods to interact with metadata. Below is an overview of the primary public methods:
### `decorate`
**Purpose:** Attach metadata to a target.
**Signature:**
```ts
decorate<
T = TClass & TProp & TCommonMateWithParam,
K extends keyof T = keyof T
>(
key: K | ((meta: T, level: TLevels, propKey?: string | symbol, index?: number) => T),
value?: T[K],
isArray?: boolean,
level?: TLevels,
): MethodDecorator & ClassDecorator & ParameterDecorator & PropertyDecorator
```
**Parameters:**
- `key`: The metadata key or a callback function to modify existing metadata.
- `value`: The value to attach to the metadata key.
- `isArray`: (Optional) Indicates if the metadata value should be treated as an array.
- `level`: (Optional) The decorator level (`'CLASS'`, `'METHOD'`, `'PROP'`, `'PARAM'`).
**Usage Example:**
```ts
// Attaching a simple metadata key-value pair
mate.decorate('customOption', 'exampleValue');
// Using a callback to modify existing metadata
mate.decorate(meta => ({
...meta,
customOption: 'modifiedValue',
}));
```
### `read`
**Purpose:** Retrieve metadata from a target.
**Signature:**
```ts
read<
PK
>(
target: TFunction | TObject,
propKey?: PK,
): PK extends PropertyKey ? (TClass & TProp & TCommonMate | undefined) : TClass | undefined
```
**Parameters:**
- `target`: The class, prototype, or object from which to read metadata.
- `propKey`: (Optional) The property key (method or property name) from which to read metadata.
**Usage Example:**
```ts
// Reading metadata from a class
const classMeta = mate.read(CustomController);
// Reading metadata from a method
const methodMeta = mate.read(CustomController.prototype, 'getUser');
```
### `apply`
**Purpose:** Apply multiple decorators to a target.
**Signature:**
```ts
apply(
...decorators: (MethodDecorator | ClassDecorator | ParameterDecorator | PropertyDecorator)[]
): (target: TObject, propKey: string | symbol, descriptor: TypedPropertyDescriptor | number) => void
```
**Usage Example:**
```ts
mate.apply(Uppercase(), AnotherDecorator())(target, propKey, descriptor);
```
### `decorateConditional`
**Purpose:** Apply decorators conditionally based on the decorator level.
**Signature:**
```ts
decorateConditional(
cb: (level: TLevels) => MethodDecorator | ClassDecorator | ParameterDecorator | PropertyDecorator | void | undefined
): MethodDecorator & ClassDecorator & ParameterDecorator & PropertyDecorator
```
**Usage Example:**
```ts
mate.decorateConditional(level => {
if (level === 'METHOD') {
return SomeMethodDecorator();
}
});
```
## Best Practices
- **Use Unique Metadata Keys:**
Always use descriptive and unique names for your custom metadata properties to avoid conflicts with Moost’s native metadata or any third-party metadata extensions.
- **Leverage TypeScript’s Typing:**
Define your metadata interfaces with TypeScript to benefit from type safety and IntelliSense support.
- **Organize Custom Decorators:**
Group related decorators in dedicated files or modules to maintain a clean and organized codebase.
- **Document Custom Metadata:**
Clearly document the purpose and usage of your custom decorators to assist team members and facilitate future maintenance.
## Further Reading
For more detailed guides, explore the following documentation pages:
- [General-Purpose Metadata](/moost/meta/common)
- [Metadata Inheritance](/moost/meta/inherit)
---
URL: "moost.org/moost/meta/inherit.html"
LLMS_URL: "moost.org/moost/meta/inherit.md"
---
# Metadata Inheritance
Moost supports inheriting metadata from superclasses, making it easier to reuse common configurations and annotations. By default, metadata defined on a superclass does not propagate to subclasses. The `@Inherit()` decorator enables this inheritance, reducing boilerplate when extending controllers, class-based interceptors, or any `@Injectable` class.
## Key Points
- **Decorator:** `@Inherit()` marks a class or method to inherit metadata from its superclass.
- **Use Cases:**
- **Controllers:** Inherit routes, prefixes, and other controller-level metadata.
- **Class-Based Interceptors:** Inherit interceptor configurations defined in a base interceptor class.
- **Injectable Classes:** Inherit dependency injection scopes, parameters, and other class-level metadata from a base service or provider class.
- **Granular Application:** Apply `@Inherit()` to an entire class or to a single method.
## Examples
### Controller Inheritance
Base class:
```ts
import { Controller, Get } from 'moost';
@Controller('base')
export class BaseController {
@Get('')
index() {
return 'hello base';
}
}
```
Subclass with inheritance:
```ts
import { Inherit, Controller } from 'moost';
@Inherit()
@Controller('extended')
export class ExtendedController extends BaseController {
index() {
return 'hello extended';
}
}
// Inherits BaseController’s `@Get('')` route while applying a new @Controller prefix.
```
### Class-Based Interceptor Inheritance
Base interceptor:
```ts
import { Intercept, Injectable } from 'moost';
@Injectable()
@Intercept(myInterceptorFn)
export class BaseInterceptor {
// Interceptor logic
}
```
Subclass:
```ts
import { Inherit } from 'moost';
@Inherit()
export class ExtendedInterceptor extends BaseInterceptor {
// Now inherits interceptor metadata from BaseInterceptor
}
```
### Injectable Classes Inheritance
Base service:
```ts
import { Injectable } from 'moost';
@Injectable()
export class BaseService {
// Some metadata and DI config
}
```
Subclass:
```ts
import { Inherit } from 'moost';
@Inherit()
export class ExtendedService extends BaseService {
// Inherits DI metadata from BaseService
}
```
## Method-Level Inheritance
You can also apply `@Inherit()` to specific methods to inherit their metadata individually.
```ts
@Controller()
class BaseController {
@Get('')
index() {
return 'base index';
}
}
class AnotherController extends BaseController {
@Inherit() // only @Get('') is inherited
index() {
return 'base index';
}
}
```
## Summary
`@Inherit()` lets you define metadata once and share it across subclasses for controllers, interceptors, injectable classes, and methods. This reduces redundancy and keeps your codebase cleaner, more maintainable, and consistent.
---
URL: "moost.org/moost/otel.html"
LLMS_URL: "moost.org/moost/otel.md"
---
# OpenTelemetry in Moost
`@moostjs/otel` integrates [OpenTelemetry](https://opentelemetry.io/) with Moost, providing automatic distributed tracing and metrics collection. It hooks into Moost's context injector system to wrap every event lifecycle phase in spans without manual instrumentation.
For the full documentation, visit the dedicated **[OpenTelemetry module section](/otel/)**.
## Quick links
| Page | What you'll learn |
|------|-------------------|
| [Overview & Quick Start](/otel/) | Installation, setup, and how it works |
| [Setup & Configuration](/otel/setup) | SDK configuration, exporters, span processors, and filtering decorators |
| [Automatic Spans](/otel/spans) | Every span Moost creates, their names, attributes, and lifecycle mapping |
| [Composables](/otel/composables) | `useOtelContext()`, `useSpan()`, `useOtelPropagation()`, child spans, and custom attributes |
| [Metrics](/otel/metrics) | Auto-collected duration histogram, attributes, and custom metric attributes |
---
URL: "moost.org/moost/pipes"
LLMS_URL: "moost.org/moost/pipes.md"
---
# Introduction to Moost Pipelines
Moost’s pipeline system provides a structured, extensible way to process data before it reaches your handlers or event-scoped classes. Pipelines consist of one or more “pipes” — functions that transform, validate, or resolve values. By organizing these operations into discrete steps, you can maintain cleaner code, reduce boilerplate, and ensure consistent data handling throughout your application.
## What Are Pipelines?
Pipelines are an ordered sequence of pipes executed against values as they travel through Moost’s runtime. For example, when a handler is invoked, Moost retrieves and processes its arguments through a pipeline, ensuring parameters are properly resolved and validated before the handler runs.
Typical operations include:
- **Resolving Data:** Extracting route parameters, parsing request bodies, or injecting dependencies.
- **Transforming Data:** Converting data formats (e.g., strings to numbers), trimming strings, or normalizing data structures.
- **Validating Data:** Ensuring the data meets certain criteria (for example, validating input with Atscript-based schemas).
Each pipe is assigned a priority (e.g., `RESOLVE`, `TRANSFORM`, `VALIDATE`), allowing Moost to run them in a logical order. Pipes also run in other contexts — such as during class instantiation for dependency injection, ensuring consistent data shaping throughout your application.
## When and Where Do Pipes Run?
Moost pipelines operate at various stages:
1. **Handler Argument Resolution:**
Before calling your handler method, Moost runs a pipeline on each parameter. For instance, route parameters, request bodies, or query parameters pass through the `RESOLVE` pipe (and possibly others) before reaching the handler. This ensures your handler receives clean, well-structured arguments.
2. **Class Properties and DI Instantiation:**
For event-scoped classes (e.g., controllers with `FOR_EVENT` scope), pipes can also run when creating class instances and injecting constructor parameters or properties. This allows pipelines to enforce consistent data validation and transformation even at the construction level.
3. **Global and Local Configuration:**
Pipes can be applied globally — affecting all handlers and classes — or selectively at the controller, method, parameter, or property level. This granular control lets you decide when and where certain transformations or validations should occur.
## The Resolve Pipe
Moost includes a default **resolve pipe** that’s always enabled. This pipe handles data extraction (e.g., `@Param` decorators) and ensures arguments are properly resolved before your handler runs. You don’t need to configure this pipe — it just works out of the box.
## Enabling Other Pipes
You can integrate additional pipes, such as validation provided by `@atscript/moost-validator`. The Atscript integration reuses the `validator()` factory emitted by Atscript models so your handlers receive fully validated DTOs without manual checks.
- **`applyGlobalPipes` Method:** Add pipes globally to all parameters or properties. For example, registering a validation pipe at the global level ensures every parameter is validated by default.
- **`@Pipe` Decorator:** Attach a pipe at the class, method, or parameter level. This approach lets you apply transformations or validations selectively, enabling fine-grained control over your data processing pipeline.
**Example of `applyGlobalPipes`**
```ts
import { Moost } from 'moost'
import { validatorPipe } from '@atscript/moost-validator'
const app = new Moost()
// ...
app.applyGlobalPipes(validatorPipe())
```
**Simplified Example `@Pipe`:**
```ts
import { UseValidatorPipe } from '@atscript/moost-validator'
@UseValidatorPipe() // Apply the pipe at the class level
class MyController {
myMethod(@Param('data') data: DTOClass) {
// `data` will be validated by the Atscript pipe before myMethod is called
}
}
```
## Custom Pipes
If Moost’s built-in pipes and external integrations don’t meet your needs, you can write custom pipes with `definePipeFn()`. This function allows you to define logic that runs at a specific priority and can transform, validate, or resolve values in any manner you choose.
For example, you might create a custom pipe to:
- Convert all string inputs to lowercase.
- Strip HTML tags from user-generated content.
- Perform custom authorization checks before the handler executes.
We’ll cover writing custom pipes in more detail in a dedicated guide.
---
URL: "moost.org/moost/pipes/custom.html"
LLMS_URL: "moost.org/moost/pipes/custom.md"
---
# Custom Pipes in Moost
This guide walks you through creating a custom pipe using Moost’s metadata system, enabling parameter-specific transformations.
## Key Concepts
1. **Metadata System:**
Moost utilizes a metadata-driven approach to parameterize pipes. By decorating parameters or properties, you attach metadata that guides how pipes should process each piece of data.
2. **Pipeline Execution:**
During pipeline execution, pipes read the associated metadata to determine the appropriate transformation logic. This ensures that each data element is processed according to its specific requirements.
3. **Decorator Integration:**
Custom decorators are used to attach metadata to parameters or properties, which the pipes then utilize during execution.
## Creating a Custom Pipe: Case Transformation Example
Let’s create a custom pipe that transforms string values to different cases — uppercase, lowercase, camelCase, and PascalCase — based on metadata applied via decorators.
### Step 1: Define the Pipe and Decorators
First, create a file named `case-pipe.ts` and define the custom pipe along with decorators to specify the desired case transformation.
```ts
// case-pipe.ts
import { definePipeFn, getMoostMate, TPipePriority } from "moost";
// Define a custom metadata type to store the case-pipe option
type TCustomMeta = {
casePipeOption?: "lower" | "upper" | "camel" | "pascal";
};
// Get the @prostojs/mate instance used in Moost
const mate = getMoostMate();
// Define decorators for case transformations
export const Uppercase = () => mate.decorate("casePipeOption", "upper");
export const Lowercase = () => mate.decorate("casePipeOption", "lower");
export const Camelcase = () => mate.decorate("casePipeOption", "camel");
export const Pascalcase = () => mate.decorate("casePipeOption", "pascal");
// Define the case-pipe
export const casePipe = definePipeFn((value, metas, level) => {
const caseOption =
metas.paramMeta?.casePipeOption ||
metas.methodMeta?.casePipeOption ||
metas.classMeta?.casePipeOption;
switch (caseOption) {
case "upper":
return typeof value === "string" ? value.toUpperCase() : value;
case "lower":
return typeof value === "string" ? value.toLowerCase() : value;
case "camel":
return typeof value === "string"
? value
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) =>
index === 0 ? word.toLowerCase() : word.toUpperCase()
)
.replace(/\s+/g, "")
: value;
case "pascal":
return typeof value === "string"
? value
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => word.toUpperCase())
.replace(/\s+/g, "")
: value;
default:
return value;
}
}, TPipePriority.TRANSFORM);
```
**Explanation:**
- **Metadata Definition (`TCustomMeta`):**
Defines a custom metadata type to store the case transformation option.
::: warning
When defining custom metadata for your pipes, **ensure that the metadata property names are unique and do not conflict** with Moost's native metadata or any third-party metadata. In this example, the metadata property `casePipeOption` is used specifically for the custom case transformation pipe. Using unique names prevents unintended behavior and maintains compatibility within your application.
```ts
type TCustomMeta = {
casePipeOption?: "lower" | "upper" | "camel" | "pascal";
};
```
**Note:** Always choose distinctive names for custom metadata properties to avoid collisions with existing or future metadata keys used by Moost or other integrated libraries.
:::
- **Decorators (`Uppercase`, `Lowercase`, `Camelcase`, `Pascalcase`):**
These decorators attach the desired case transformation option to parameters or properties.
- **Pipe Definition (`casePipe`):**
The `casePipe` function reads the metadata and applies the appropriate case transformation to the input value.
### Step 2: Apply the Custom Pipe in a Controller
Next, create a controller that utilizes the custom pipe to transform input data based on the attached decorators.
```ts
// controllers/app.controller.ts
import { Get, Query } from '@moostjs/event-http';
import { Controller, Param, Pipe } from 'moost';
import { casePipe, Pascalcase, Uppercase } from '../case-pipe';
@Pipe(casePipe) // Apply the case-pipe to the controller
@Controller()
export class AppController {
@Get("hello/:name")
greet(
@Uppercase() // Transform the 'name' parameter to uppercase
@Param("name")
name: string,
@Pascalcase() // Transform the 'jobTitle' query parameter to PascalCase
@Query("jobTitle")
jobTitle: string
) {
return `Hello, ${name}!\nJob Title: ${jobTitle || "unknown"}`;
}
}
```
**Explanation:**
- **Controller-Level Pipe (`@Pipe(casePipe)`):**
Applies the `casePipe` to all parameters and properties within the `AppController`.
- **Parameter Decorators (`@Uppercase()`, `@Pascalcase()`):**
Attach specific case transformation metadata to individual parameters.
- **Handler Method (`greet`):**
Receives transformed parameters based on the attached decorators.
### Step 3: Testing the Custom Pipe
Start your Moost application and test the custom pipe functionality using `curl` or any HTTP client.
```bash
curl -X GET http://localhost:3000/hello/Joe?jobTitle=node%20js%20backend%20developer
```
**Expected Response:**
```
Hello, JOE!
Job Title: NodeJsBackendDeveloper
```
**Explanation:**
- The `name` parameter (`Joe`) is transformed to uppercase (`JOE`) due to the `@Uppercase()` decorator.
- The `jobTitle` query parameter (`node js backend developer`) is transformed to PascalCase (`NodeJsBackendDeveloper`) due to the `@Pascalcase()` decorator.
## Detailed Breakdown
### 1. Defining Custom Decorators
Custom decorators (`Uppercase`, `Lowercase`, `Camelcase`, `Pascalcase`) are created using Moost’s metadata system. These decorators attach specific transformation options to the parameters or properties they decorate.
```ts
export const Uppercase = () => mate.decorate("casePipeOption", "upper");
export const Lowercase = () => mate.decorate("casePipeOption", "lower");
export const Camelcase = () => mate.decorate("casePipeOption", "camel");
export const Pascalcase = () => mate.decorate("casePipeOption", "pascal");
```
### 2. Creating the Pipe Function
The `casePipe` function processes the input value based on the attached metadata. It reads the `casePipeOption` from metadata and applies the corresponding transformation.
```ts
export const casePipe = definePipeFn((value, metas, level) => {
const caseOption =
metas.paramMeta?.casePipeOption ||
metas.methodMeta?.casePipeOption ||
metas.classMeta?.casePipeOption;
switch (caseOption) {
case "upper":
return typeof value === "string" ? value.toUpperCase() : value;
case "lower":
return typeof value === "string" ? value.toLowerCase() : value;
case "camel":
return typeof value === "string"
? value
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) =>
index === 0 ? word.toLowerCase() : word.toUpperCase()
)
.replace(/\s+/g, "")
: value;
case "pascal":
return typeof value === "string"
? value
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => word.toUpperCase())
.replace(/\s+/g, "")
: value;
default:
return value;
}
}, TPipePriority.TRANSFORM);
```
### 3. Attaching Pipes via Decorators
Decorators are used to attach the custom pipe metadata to specific parameters or properties. When the pipeline runs, it reads this metadata to apply the transformations.
```ts
@Pipe(casePipe) // Apply the custom pipe to the controller
@Controller()
export class AppController {
@Get("hello/:name")
greet(
@Uppercase() // Apply uppercase transformation
@Param("name")
name: string,
@Pascalcase() // Apply PascalCase transformation
@Query("jobTitle")
jobTitle: string
) {
return `Hello, ${name}!\nJob Title: ${jobTitle || "unknown"}`;
}
}
```
---
URL: "moost.org/moost/pipes/resolve.html"
LLMS_URL: "moost.org/moost/pipes/resolve.md"
---
# Resolve Pipe in Moost
The **Resolve Pipe** in Moost is responsible for extracting and preparing the data your handlers and event-scoped classes need — without requiring you to write boilerplate parsing or data-fetching code. This pipeline runs automatically before a handler is invoked and leverages "resolvers" — decorators that declare how to retrieve or compute values for handler parameters or class properties.
## What Is the Resolve Pipe?
The Resolve Pipe is always active in Moost, ensuring that each handler argument is fully prepared before the handler method runs. At its core, it transforms abstract instructions (like "this parameter should be the `:id` route parameter" or "this property should be the current date") into concrete values. By the time your handler executes, all arguments are properly resolved, and the handler can focus purely on application logic.
## How Resolvers Work
Resolvers are decorators that you apply directly to parameters or properties. They define a small function that fetches or computes a value at runtime. For instance:
- **`@Param('name')`**: Extracts a route parameter named `name`.
- **`@Params()`**: Injects an object of all route parameters.
- **`@Const(value)`** and **`@ConstFactory()`**: Provide fixed or dynamically computed constants.
- **`@InjectEventLogger()`**: Supplies an event-aware logger instance.
- Other resolvers are provided by event-specific modules, e.g. resolver `@Body()` that parses body of incoming HTTP request is provided by `@moostjs/event-http` module.
At the Resolve Pipe stage, Moost reads a resolver function defined for a given parameter or property and executes it.
**Example:**
```ts
import { Controller, Param, Get } from 'moost';
@Controller('api')
class MyController {
@Get('users/:id')
getUser(@Param('id') userId: string) { // [!code hl]
// `userId` is automatically resolved from the route parameter
return `User ID: ${userId}`;
}
}
```
In this example, the `@Param('id')` resolver runs during the Resolve Pipe and supplies the `userId` argument before `getUser()` is called.
## The `@Resolve` Decorator for Custom Resolvers
Moost provides a `@Resolve` decorator, the foundational API for building custom resolvers. All built-in resolvers like `@Param` or `@InjectEventLogger` are constructed using `@Resolve`. This empowers you to create your own resolvers for any data retrieval logic unique to your application.
**Example:**
```ts
import { Controller, Get } from 'moost';
import { Resolve } from 'moost'; // Base decorator for custom resolvers
function CurrentDate() { // [!code hl]
return Resolve(() => new Date().toISOString()); // [!code hl]
} // [!code hl]
@Controller()
class MyController {
@Get()
getTimestamp(@CurrentDate() date: string) { // [!code hl]
return `Current date/time: ${date}`;
}
}
```
Here, `CurrentDate()` uses `@Resolve` to provide the current timestamp. Moost calls the resolver function during the Resolve Pipe, injecting the computed value into the `date` parameter.
## Integration with Event Context and DI
Resolvers integrate seamlessly with Moost’s event context and dependency injection (DI):
- **Event Context Access:** Since Moost is built on Wooks, resolvers can access the event context (e.g., request data) through composables. For example, `@Param` resolvers use Wooks utilities to fetch route parameters.
- **Dependency Injection:** If needed, resolvers can also leverage DI to retrieve services or configuration. For example, you could write a custom resolver that injects a configuration service and computes a value based on that service’s data.
## When Do Resolvers Run?
- **Handler Arguments:** Before your handler executes, Moost runs the Resolve Pipe on each argument. Each resolver runs, producing final argument values.
- **Injected Classes (Properties and Constructor Arguments):** Resolve Pipe also run for injected classes constructor parameters and properties. This ensures each event-scoped instance is fully prepared with resolved data from the get-go.
---
URL: "moost.org/moost/pipes/validate.html"
LLMS_URL: "moost.org/moost/pipes/validate.md"
---
# Validation Pipe with Atscript
Moost’s validation story is now powered by [Atscript](https://atscript.moost.org/). Every DTO generated by Atscript exposes a `validator(opts)` factory that `@atscript/moost-validator` uses at runtime.
By combining the Moost pipeline system with these runtime hooks you get type-safe validation without writing imperative checks or maintaining parallel schemas.
::: tip Full Atscript + Moost docs
For the complete guide — installation, configuration, and all available options — see the [`@atscript/moost-validator` documentation](https://atscript.moost.org/packages/moost-validator/).
:::
## How it works
1. **Generate annotated DTOs** with Atscript (`*.as` source files) and consume them from your application code.
2. **Register the validation pipe** provided by `@atscript/moost-validator`. The pipe inspects every handler argument and, when the declared type was annotated by Atscript, it runs `validator().validate(value)`.
3. **Optionally register the error interceptor** to convert rich validation failures into `400 Bad Request` responses.
```ts
import { Moost } from 'moost'
import { MoostHttp, Body, Post } from '@moostjs/event-http'
import { validatorPipe, validationErrorTransform } from '@atscript/moost-validator'
import { UsersController } from './users.controller'
const app = new Moost()
app.adapter(new MoostHttp())
app.applyGlobalPipes(validatorPipe())
app.applyGlobalInterceptors(validationErrorTransform())
app.registerControllers(UsersController)
await app.init()
```
With this setup every request body, query param, or DI value whose type exposes `validator()` is validated before your handler executes.
## Example DTO
```atscript
@label "Create User"
export interface CreateUserDto {
@label "Display name"
name: string
@expect.pattern "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "u", "Invalid email"
email: string
@label "Password"
password: string
@labelOptional
roles?: string[]
}
```
Atscript emits the metadata at build time. Check the [Atscript documentation](https://atscript.moost.org/) for the full authoring experience, including decorators, CLI options, and IDE integration.
## Applying the pipe locally
You can scope validation to a specific controller or method using the `@UseValidatorPipe()` decorator, or stack it with other pipes via `@Pipe()`.
```ts
import { Controller, Pipe } from 'moost'
import { Body, Post } from '@moostjs/event-http'
import { UseValidatorPipe, UseValidationErrorTransform } from '@atscript/moost-validator'
import { CreateUserDto } from './create-user.dto.as'
@UseValidatorPipe()
@UseValidationErrorTransform()
@Controller('users')
export class UsersController {
@Post()
async create(@Body() dto: CreateUserDto) {
// dto is guaranteed to satisfy the Atscript schema
}
}
```
### Per-parameter configuration
`validatorPipe(opts)` accepts any subset of [`TValidatorOptions`](https://github.com/moostjs/atscript/blob/main/packages/typescript/src/validator.ts). For instance, to allow unknown properties and collect all errors:
```ts
app.applyGlobalPipes(
validatorPipe({
unknwonProps: 'ignore',
errorLimit: 50,
}),
)
```
> The options are passed straight to Atscript’s validator, so you get feature parity with the standalone runtime.
## Handling validation errors
When validation fails `validator.validate()` throws `ValidatorError`. Without the interceptor Moost would treat the error as unhandled and return `500 Internal Server Error`. The provided interceptor (`validationErrorTransform()` or `@UseValidationErrorTransform()`) converts it into `HttpError(400)` with the underlying details exposed in the response body:
```json
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
{ "path": "email", "message": "Invalid email" },
{ "path": "password", "message": "Length must be >= 12" }
]
}
```
Adapters such as `@moostjs/event-http` serialise that object as JSON automatically.
## Validating beyond HTTP
Because the pipe hooks into Moost’s generic pipeline system you can reuse it for:
- CLI handlers in `@moostjs/event-cli`
- Workflow steps in `@moostjs/event-wf`
- Any custom event adapter you build
Wherever Moost can resolve parameters, `validatorPipe()` can run.
---
URL: "moost.org/moost/what.html"
LLMS_URL: "moost.org/moost/what.md"
---
# What is Moost?
Moost is a TypeScript framework with controllers, dependency injection, pipes, and interceptors for HTTP servers, WebSocket apps, CLI tools, and workflow engines. If you've used NestJS, the shape will feel familiar, but Moost drops the module ceremony and builds on [Wooks](https://wooks.moost.org/wooks/what) for lazy, composable event handling.
## A Quick Look
```ts
import { Get } from '@moostjs/event-http'
import { Controller, Param } from 'moost'
@Controller('api')
export class AppController {
@Get('hello/:name')
greet(@Param('name') name: string) {
return `Hello, ${name}!`
}
}
```
Decorators declare your routes, DI scopes, validation rules, and access control. They write to a shared metadata store ([`@prostojs/mate`](https://github.com/prostojs/mate)) that Moost reads at startup to wire everything together. Creating your own decorators is just a few lines of typed code.
## Dependency Injection
Mark a class `@Injectable()` and Moost handles the rest — no modules, no providers arrays, no `forRoot()`. Two scopes:
- **Singleton** — one instance for the entire app
- **For Event** — a fresh instance per request, command, or message
```ts
import { Injectable } from 'moost'
@Injectable('FOR_EVENT')
export class RequestLogger {
log(msg: string) { /* ... */ }
}
```
Dependencies are resolved by constructor type. Use `@Provide()` / `@Inject()` when you need to swap implementations.
## Controllers
Controllers group handlers under a shared prefix. Nest them to build route hierarchies:
```ts
import { Controller, ImportController } from 'moost'
@Controller('admin')
@ImportController(UsersController)
@ImportController(SettingsController)
export class AdminController { }
```
A single controller can serve multiple event types at once — `@Get()` registers an HTTP route, `@Cli()` a CLI command, `@Message()` a WebSocket handler. Each adapter picks up only what it understands.
## Interceptors and Pipes
**Interceptors** wrap your handlers with lifecycle hooks — `before`, `after`, `error`. Great for auth guards, logging, or response transformation. Priority levels let you control the order.
**Pipes** process handler arguments step by step: resolve, transform, validate. Route params, query strings, and body data are extracted automatically. Add a validation layer (e.g., Atscript) to reject bad input before your handler even runs.
## Wooks Composables
Every Wooks composable works inside a Moost handler, so you can reach for typed request data whenever you need it:
```ts
import { Get } from '@moostjs/event-http'
import { useRequest, useCookies } from '@wooksjs/event-http'
import { Controller } from 'moost'
@Controller()
export class AppController {
@Get('profile')
profile() {
const { method, url } = useRequest()
const cookies = useCookies()
return { method, url, session: cookies.get('session') }
}
}
```
Moost adds structure on top; Wooks provides the composable runtime underneath. You decide how much of each you want to use.
## Packages
| Package | What it does |
|---------|------|
| `moost` | Core framework — decorators, DI, pipes, interceptors |
| `@moostjs/event-http` | HTTP adapter (wraps `@wooksjs/event-http`) |
| `@moostjs/event-ws` | WebSocket adapter (wraps `@wooksjs/event-ws`) |
| `@moostjs/event-cli` | CLI adapter (wraps `@wooksjs/event-cli`) |
| `@moostjs/event-wf` | Workflow adapter (wraps `@wooksjs/event-wf`) |
| `@moostjs/swagger` | Swagger / OpenAPI generation |
| `@moostjs/otel` | OpenTelemetry tracing |
---
URL: "moost.org/moost/why.html"
LLMS_URL: "moost.org/moost/why.md"
---
# Why Moost?
## The Short Version
Moost is for teams that like NestJS-style structure but do not want NestJS-style ceremony. You keep controllers, decorators, DI, pipes, and interceptors, but drop the module boilerplate and gain one consistent model across HTTP, WebSocket, CLI, and workflows.
## How We Got Here
NestJS showed that Node.js servers could be well-structured. But over time, the overhead adds up. Modules wrapping modules. The same class name repeated in three different arrays. A metadata system that's hard to extend. And when you need something beyond HTTP — a CLI tool, a workflow engine — the story gets complicated.
[Wooks](https://wooks.moost.org/wooks/what) took a different path: composable, typed access to event data without middleware chains. But it was purely functional — no DI, no decorator-driven structure. Moost bridges the gap: **NestJS-style architecture on top of Wooks, without the ceremony.**
## What's Different
### Just register your controllers
NestJS asks you to wrap everything in modules — imports, exports, providers, controllers. For most projects, that's boilerplate. In Moost, you register controllers directly and the DI container figures out the rest. Fewer files, less indirection.
### Data on demand
Traditional middleware parses headers, cookies, and request bodies upfront — whether your handler needs them or not. Moost inherits Wooks' composable model: call `useRequest()`, `useCookies()`, or `useBody()` inside your handler and get typed data only when you ask for it. This also means better performance — nothing is computed until it's needed.
### Easy custom decorators
Moost's metadata layer ([`@prostojs/mate`](https://github.com/prostojs/mate)) makes creating custom decorators straightforward — a few lines of typed code. No `Reflect.metadata` internals, no adapter boilerplate.
### Beyond HTTP
HTTP, CLI, WebSocket, and workflows all share the same core: controllers, DI, interceptors, pipes. Write an auth guard once, use it everywhere. Adapters handle the transport differences so your business logic doesn't have to.
### Fast by default
Moost's DI layer adds roughly **half the overhead** of NestJS's. Combined with Wooks' lazy parsing, it's consistently faster across realistic workloads. Details in the [benchmark results](/webapp/benchmarks).
## When to Choose Moost
- You want structured, decorator-driven code without the module boilerplate
- Your app spans multiple event types (HTTP + CLI, HTTP + WebSocket)
- You prefer explicit composable functions over implicit middleware
- You value TypeScript ergonomics and want decorators that are easy to extend
---
URL: "moost.org/otel"
LLMS_URL: "moost.org/otel.md"
---
# @moostjs/otel
`@moostjs/otel` integrates [OpenTelemetry](https://opentelemetry.io/) with Moost, providing **automatic distributed tracing** and **metrics collection** for every event your application handles. It hooks into Moost's context injector system to wrap each lifecycle phase — interceptors, argument resolution, handler execution — in spans, without any manual instrumentation in your handler code.
## What you get
- **Automatic spans** for every event lifecycle phase (interceptors, arg resolution, handler call)
- **Event duration metrics** with route, event type, and error status attributes
- **Trace propagation** helpers for passing context to downstream services
- **Custom span & metric attributes** from within your handlers
- **Selective filtering** — suppress spans or metrics for health checks, internal endpoints, etc.
- **Drop-in span processors** that respect filtering decorators
## Installation
```bash
npm install @moostjs/otel @opentelemetry/api @opentelemetry/sdk-trace-base
```
You will also need an exporter for your tracing backend (Jaeger, Zipkin, OTLP, etc.):
```bash
# Example: OTLP exporter (works with Jaeger, Grafana Tempo, etc.)
npm install @opentelemetry/exporter-trace-otlp-http
```
## Quick start
The only Moost-specific step is calling `enableOtelForMoost()` before creating your app. Everything else — tracer providers, exporters, span processors — is standard OpenTelemetry SDK configuration that varies by project and infrastructure.
::: tip
The OpenTelemetry SDK setup below is just one possible configuration. Your project may use different providers, exporters, or initialization patterns depending on your tracing backend and deployment environment. Refer to the [OpenTelemetry JS documentation](https://opentelemetry.io/docs/languages/js/) for the full range of options.
:::
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { enableOtelForMoost, MoostBatchSpanProcessor } from '@moostjs/otel'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
// 1. Configure OpenTelemetry SDK (your setup may differ)
const provider = new NodeTracerProvider()
provider.addSpanProcessor(
new MoostBatchSpanProcessor(new OTLPTraceExporter())
)
provider.register()
// 2. Enable Moost instrumentation (before creating the app)
enableOtelForMoost()
// 3. Create and start your app as usual
const app = new Moost()
app.adapter(new MoostHttp()).listen(3000)
await app.init()
```
Every incoming event now produces spans and metrics automatically — no decorator annotations needed on your handlers.
## How it works
```
Incoming event
└─ Event:start span (wraps entire lifecycle)
├─ Interceptors:before
│ └─ Interceptor:MyGuard (per-interceptor span)
│ └─ Arguments:resolve (if interceptor has params)
├─ Arguments:resolve (handler params)
├─ Handler:/users/:id (handler execution)
└─ Interceptors:after
└─ Interceptor:MyGuard (per-interceptor span)
```
Moost's **context injector** system powers this. When you call `enableOtelForMoost()`, it replaces the default no-op injector with a `SpanInjector` that wraps every lifecycle phase in an OpenTelemetry span. The injector also:
- Sets span attributes with controller name, handler method, route, and event type
- Records exceptions and sets error status on spans
- Collects event duration metrics via an OpenTelemetry histogram
- Applies custom attributes you add from handler code
## What to read next
| Page | What you'll learn |
|------|-------------------|
| [Setup & Configuration](/otel/setup) | Full SDK setup, exporters, span processors, and `@OtelIgnoreSpan` / `@OtelIgnoreMeter` decorators |
| [Automatic Spans](/otel/spans) | Every span Moost creates, their names, attributes, and how they map to the event lifecycle |
| [Composables](/otel/composables) | `useOtelContext()`, `useSpan()`, `useOtelPropagation()`, child spans, and custom attributes |
| [Metrics](/otel/metrics) | Auto-collected duration histogram, recorded attributes, and custom metric attributes |
---
URL: "moost.org/otel/composables.html"
LLMS_URL: "moost.org/otel/composables.md"
---
# Composables
`@moostjs/otel` provides composables for accessing OpenTelemetry context from within your event handlers. Like all Moost composables, they must be called inside an active event context.
## `useOtelContext()`
The main composable for interacting with OpenTelemetry. Returns an object with tracing utilities scoped to the current event.
```ts
import { useOtelContext } from '@moostjs/otel'
@Get('users/:id')
async getUser(@Param('id') id: string) {
const {
trace, // OpenTelemetry trace API
getSpan, // Get the current event span
getSpanContext, // Get span context (traceId, spanId)
getPropagationHeaders, // Get W3C headers for outgoing requests
withChildSpan, // Create a child span
customSpanAttr, // Add attribute to the event span
customMetricAttr, // Add attribute to event metrics
} = useOtelContext()
customSpanAttr('user.id', id)
return fetchUser(id)
}
```
### `customSpanAttr(name, value)`
Adds a custom attribute to the root event span. Attributes are applied when the span ends, so you can call this at any point during the handler lifecycle.
```ts
const { customSpanAttr } = useOtelContext()
customSpanAttr('user.id', '123')
customSpanAttr('user.role', 'admin')
customSpanAttr('cache.hit', 1)
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `string` | Attribute key |
| `value` | `string \| number` | Attribute value |
### `customMetricAttr(name, value)`
Adds a custom attribute to the event duration metric. Like span attributes, these are applied when the event completes.
```ts
const { customMetricAttr } = useOtelContext()
customMetricAttr('tenant.id', 'acme')
customMetricAttr('response.cached', 1)
```
### `getSpan()`
Returns the root span for the current event, or `undefined` if no span is active.
```ts
const { getSpan } = useOtelContext()
const span = getSpan()
span?.addEvent('cache-miss', { key: 'user:123' })
```
### `getSpanContext()`
Returns the span context containing `traceId`, `spanId`, and `traceFlags`.
```ts
const { getSpanContext } = useOtelContext()
const ctx = getSpanContext()
console.log(`Trace: ${ctx?.traceId}, Span: ${ctx?.spanId}`)
```
### `getPropagationHeaders()`
Returns W3C trace-context headers (`traceparent`, `tracestate`) for propagating traces to downstream services.
```ts
const { getPropagationHeaders } = useOtelContext()
const headers = getPropagationHeaders()
const response = await fetch('http://user-service/api/users', {
headers: {
...headers,
'Content-Type': 'application/json',
},
})
```
### `withChildSpan(name, callback, options?)`
Creates a child span and returns a function that executes the callback within that span's context. This two-step API lets you prepare the span and invoke it separately.
```ts
const { withChildSpan } = useOtelContext()
// Create and immediately invoke
const result = await withChildSpan('fetch-user-data', async () => {
return await db.users.findById(id)
})()
// Or prepare and invoke later
const fetchData = withChildSpan('fetch-user-data', async () => {
return await db.users.findById(id)
})
const data = await fetchData()
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `string` | Span name |
| `callback` | `(...args) => T` | Function to execute within the span |
| `options?` | `SpanOptions` | OpenTelemetry span options (kind, attributes, links, etc.) |
::: tip
`withChildSpan` creates the span immediately but only activates its context when you call the returned function. The span remains open until the callback completes.
:::
## `useTrace()`
Shorthand to access the OpenTelemetry `trace` API directly. Useful when you need to create custom tracers.
```ts
import { useTrace } from '@moostjs/otel'
const trace = useTrace()
const tracer = trace.getTracer('my-custom-tracer')
const span = tracer.startSpan('custom-operation')
```
## `useSpan()`
Shorthand that returns the root span for the current event. Equivalent to `useOtelContext().getSpan()`.
```ts
import { useSpan } from '@moostjs/otel'
const span = useSpan()
span?.setAttribute('custom.key', 'value')
span?.addEvent('checkpoint', { step: 'validation-complete' })
```
## `useOtelPropagation()`
Returns trace propagation data including the span context fields and ready-to-use HTTP headers.
```ts
import { useOtelPropagation } from '@moostjs/otel'
const { traceId, spanId, traceFlags, traceState, headers } = useOtelPropagation()
// Pass headers to downstream HTTP calls
const response = await fetch('http://downstream-service/api', {
headers: {
...headers,
'Content-Type': 'application/json',
},
})
```
| Field | Type | Description |
|-------|------|-------------|
| `traceId` | `string?` | 32-character hex trace ID |
| `spanId` | `string?` | 16-character hex span ID |
| `traceFlags` | `number?` | Trace flags (sampled, etc.) |
| `traceState` | `TraceState?` | Vendor-specific trace state |
| `headers` | `object` | `{ traceparent?, tracestate? }` — W3C headers |
## `withSpan()`
Low-level utility for executing a callback within a span context. Unlike `withChildSpan` from `useOtelContext()`, this is a standalone function that doesn't require an active event context.
```ts
import { withSpan } from '@moostjs/otel'
// Create a span from name + options
const result = withSpan({ name: 'db-query' }, () => {
return db.query('SELECT * FROM users')
})
// Use an existing span
const tracer = trace.getTracer('default')
const span = tracer.startSpan('my-op')
const result = withSpan(span, () => doWork())
```
### With post-processing
When you pass a `postProcess` callback, **you are responsible for ending the span**:
```ts
withSpan(
{ name: 'db-query' },
() => db.query('SELECT * FROM users'),
(span, exception, result) => {
if (result) {
span.setAttribute('db.row_count', result.length)
}
span.end() // You must end the span yourself
}
)
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `span` | `TSpanInput \| Span` | Span name + options, or an existing span |
| `cb` | `() => T` | Callback to execute (sync or async) |
| `postProcess?` | `(span, error?, result?) => void` | Post-processing callback. **You must call `span.end()`** when using this. |
Without `postProcess`, the span is ended automatically after the callback completes (or the returned promise resolves).
---
URL: "moost.org/otel/metrics.html"
LLMS_URL: "moost.org/otel/metrics.md"
---
# Metrics
`@moostjs/otel` automatically collects event duration metrics using an OpenTelemetry histogram. Metrics are recorded when each event completes, capturing timing, route, event type, and error status.
## Auto-collected metric
### `moost.event.duration`
A histogram that records the duration of each event in milliseconds.
| Property | Value |
|----------|-------|
| **Meter** | `moost-meter` |
| **Instrument** | Histogram |
| **Unit** | `ms` |
| **Description** | `Moost Event Duration Histogram` |
Every completed event (HTTP request, CLI command, workflow execution) records a single data point to this histogram.
## Recorded attributes
Each metric data point includes these attributes:
| Attribute | Type | Description |
|-----------|------|-------------|
| `route` | `string` | The matched route (e.g. `/users/:id`). For HTTP events that don't match a route, falls back to the raw URL. |
| `moost.event_type` | `string` | Event type: `HTTP`, `CLI`, `WF`, etc. |
| `moost.is_error` | `number` | `1` if the event resulted in an error, `0` otherwise |
### HTTP-specific attributes
For HTTP events, two additional attributes are recorded:
| Attribute | Type | Description |
|-----------|------|-------------|
| `http.status_code` | `number` | Response status code (e.g. `200`, `404`, `500`) |
| `moost.is_error` | `number` | Also set to `1` if `http.status_code > 399` |
::: tip
The status code is captured by patching the response's `writeHead()` method internally. This works automatically — no additional setup is needed.
:::
## Custom metric attributes
Add custom attributes to the event metrics from within your handler code using `customMetricAttr()`:
```ts
import { useOtelContext } from '@moostjs/otel'
@Get('users/:id')
async getUser(@Param('id') id: string) {
const { customMetricAttr } = useOtelContext()
customMetricAttr('tenant.id', 'acme-corp')
customMetricAttr('cache.hit', 0)
const user = await db.users.findById(id)
customMetricAttr('cache.hit', 1) // Override — last value wins
return user
}
```
Custom attributes are merged with the auto-collected attributes when the metric is recorded. They can be strings or numbers.
## Suppressing metrics
Use the `@OtelIgnoreMeter()` decorator to suppress metrics for specific controllers or handlers:
```ts
import { Controller } from 'moost'
import { Get } from '@moostjs/event-http'
import { OtelIgnoreMeter } from '@moostjs/otel'
// Suppress metrics for the entire controller
@Controller('health')
@OtelIgnoreMeter()
class HealthController {
@Get()
check() {
return { status: 'ok' }
}
}
```
Handler-level decorator:
```ts
@Controller('api')
class ApiController {
@Get('status')
@OtelIgnoreMeter()
status() {
// No metrics recorded for this endpoint
return { ok: true }
}
@Get('users')
users() {
// Metrics ARE recorded for this endpoint
return []
}
}
```
## Metrics SDK setup
To export metrics, configure the OpenTelemetry Metrics SDK alongside the tracing SDK. The exact setup depends on your metrics backend and infrastructure — the example below is one possible configuration using OTLP over HTTP.
```ts
// Example — adjust for your metrics backend
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
const meterProvider = new MeterProvider({
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: 'http://localhost:4318/v1/metrics',
}),
exportIntervalMillis: 10000,
}),
],
})
```
The `moost-meter` meter is created lazily on the first event, using OpenTelemetry's global meter provider. See the [OpenTelemetry Metrics SDK docs](https://opentelemetry.io/docs/languages/js/instrumentation/#metrics) for other configuration options.
## Example: Grafana dashboard query
With metrics exported to Prometheus (via OTLP), you can query event durations:
```txt
# Average request duration by route
histogram_quantile(0.95,
sum(rate(moost_event_duration_bucket{moost_event_type="HTTP"}[5m])) by (le, route)
)
# Error rate by route
sum(rate(moost_event_duration_count{moost_is_error="1"}[5m])) by (route)
/
sum(rate(moost_event_duration_count[5m])) by (route)
```
---
URL: "moost.org/otel/setup.html"
LLMS_URL: "moost.org/otel/setup.md"
---
# Setup & Configuration
This page covers enabling Moost's OpenTelemetry integration, Moost-specific span processors, and filtering decorators. It also shows example SDK configurations, though **your actual OpenTelemetry SDK setup will depend on your project's infrastructure, tracing backend, and deployment environment**.
::: tip
`@moostjs/otel` adds Moost-specific instrumentation on top of the standard OpenTelemetry SDK. The SDK setup itself (providers, exporters, resource attributes, sampling) is not Moost-specific — refer to the [OpenTelemetry JS documentation](https://opentelemetry.io/docs/languages/js/) for comprehensive guidance on configuring the SDK for your needs.
:::
## OpenTelemetry SDK setup
Before enabling Moost instrumentation, configure the OpenTelemetry SDK. A typical setup involves three pieces: a **tracer provider**, a **span processor**, and an **exporter**. The exact configuration varies by project — the example below uses OTLP over HTTP, but you might use Jaeger, Zipkin, a console exporter, or any other backend.
```ts
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { MoostBatchSpanProcessor } from '@moostjs/otel'
// Example configuration — adjust for your tracing backend
const provider = new NodeTracerProvider()
provider.addSpanProcessor(
new MoostBatchSpanProcessor(
new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
})
)
)
provider.register()
```
::: tip
Call `provider.register()` **before** `enableOtelForMoost()` so the global tracer is available when Moost starts creating spans.
:::
## Enabling Moost instrumentation
```ts
import { enableOtelForMoost } from '@moostjs/otel'
enableOtelForMoost()
```
This replaces Moost's default context injector with the `SpanInjector`. Call it once, **after** registering the tracer provider and **before** creating your Moost application instance.
### Initialization order
Regardless of how you configure the SDK, the order matters:
1. **Register the tracer provider** (so the global tracer is available)
2. **Call `enableOtelForMoost()`** (replaces Moost's context injector)
3. **Create and start your Moost app**
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { enableOtelForMoost, MoostBatchSpanProcessor } from '@moostjs/otel'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
// 1. Tracer provider + exporter (your setup may differ)
const provider = new NodeTracerProvider()
provider.addSpanProcessor(
new MoostBatchSpanProcessor(new OTLPTraceExporter())
)
provider.register()
// 2. Enable Moost instrumentation
enableOtelForMoost()
// 3. Create and start the app
const app = new Moost()
app.adapter(new MoostHttp()).listen(3000)
await app.init()
```
## Span processors
OpenTelemetry span processors control how spans are batched and exported. Moost provides drop-in replacements that add **span filtering** support via the `@OtelIgnoreSpan()` decorator.
### `MoostBatchSpanProcessor`
Extends the standard `BatchSpanProcessor`. Spans marked with `@OtelIgnoreSpan()` are silently dropped before batching — they never reach the exporter.
```ts
import { MoostBatchSpanProcessor } from '@moostjs/otel'
provider.addSpanProcessor(
new MoostBatchSpanProcessor(exporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
})
)
```
Use this for production. It batches spans and exports them periodically, reducing overhead.
### `MoostSimpleSpanProcessor`
Extends the standard `SimpleSpanProcessor`. Exports spans immediately (one by one) while still filtering out ignored spans.
```ts
import { MoostSimpleSpanProcessor } from '@moostjs/otel'
provider.addSpanProcessor(
new MoostSimpleSpanProcessor(exporter)
)
```
Use this for development or debugging when you want to see spans in real time.
::: warning
`@OtelIgnoreSpan()` only works with `MoostBatchSpanProcessor` or `MoostSimpleSpanProcessor`. If you use the standard OpenTelemetry processors directly, ignored spans will still be exported.
:::
## Filtering with decorators
### `@OtelIgnoreSpan()`
Prevents spans from being exported for the decorated controller or handler. The spans are still created internally (so child spans can reference a parent), but they are dropped by the Moost span processors before export.
```ts
import { Controller } from 'moost'
import { Get } from '@moostjs/event-http'
import { OtelIgnoreSpan } from '@moostjs/otel'
@Controller('health')
@OtelIgnoreSpan()
class HealthController {
@Get()
check() {
return { status: 'ok' }
}
}
```
Apply at handler level to ignore a single endpoint:
```ts
@Controller('api')
class ApiController {
@Get('status')
@OtelIgnoreSpan()
status() {
return { ok: true }
}
@Get('users')
users() {
// This handler's spans ARE exported
return []
}
}
```
### `@OtelIgnoreMeter()`
Suppresses **metrics collection** for the decorated controller or handler. Spans are still created and exported normally.
```ts
import { OtelIgnoreMeter } from '@moostjs/otel'
@Controller('internal')
@OtelIgnoreMeter()
class InternalController {
@Get('debug')
debug() {
return { info: 'debug data' }
}
}
```
::: tip
Handler-level decorators override controller-level decorators. If a controller has `@OtelIgnoreSpan()` but a specific handler does not, that handler's spans will still be filtered because it inherits the controller's setting.
:::
## HTTP instrumentation
For HTTP events, `@moostjs/otel` expects the root span to be created by the OpenTelemetry HTTP instrumentation (`@opentelemetry/instrumentation-http`). The `SpanInjector` attaches to the existing HTTP span rather than creating a new one, and patches the response to capture status codes for metrics.
```ts
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
import { registerInstrumentations } from '@opentelemetry/instrumentation'
registerInstrumentations({
instrumentations: [new HttpInstrumentation()],
})
```
For non-HTTP event types (CLI, Workflow, custom), the `SpanInjector` creates the root span itself (named `"{EventType} Event"`).
## Exporter examples
These are common exporter configurations for reference. Your project may use a different exporter or configuration entirely — see the [OpenTelemetry registry](https://opentelemetry.io/ecosystem/registry/?language=js&component=exporter) for the full list of available exporters.
### Jaeger (via OTLP)
```ts
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
const exporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
})
```
### Zipkin
```ts
import { ZipkinExporter } from '@opentelemetry/exporter-zipkin'
const exporter = new ZipkinExporter({
url: 'http://localhost:9411/api/v2/spans',
})
```
### Console (development)
```ts
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base'
const exporter = new ConsoleSpanExporter()
```
---
URL: "moost.org/otel/spans.html"
LLMS_URL: "moost.org/otel/spans.md"
---
# Automatic Spans
When `enableOtelForMoost()` is active, every event produces a tree of spans that mirrors Moost's [event lifecycle](/moost/event-lifecycle). No decorators or manual instrumentation are needed — spans are created automatically by the `SpanInjector` context injector.
## Span tree
Here is the full span hierarchy for a typical HTTP request with one interceptor:
```
Event:start ← root event span
├─ Interceptors:before ← before phase wrapper
│ └─ Interceptor:AuthGuard ← per-interceptor span
│ └─ Arguments:resolve ← interceptor param resolution
├─ Arguments:resolve ← handler param resolution
├─ Handler:/users/:id ← handler execution
└─ Interceptors:after ← after phase wrapper
└─ Interceptor:AuthGuard ← per-interceptor span
└─ Arguments:resolve ← interceptor param resolution
```
Each span corresponds to a phase in the [event lifecycle](/moost/event-lifecycle). The root `Event:start` span wraps the entire lifecycle and closes only after all inner phases complete.
## Span reference
### Event:start
The root span that wraps the entire event lifecycle. Created when an event enters the system.
| Property | Value |
|----------|-------|
| **Name** | Updated to `{METHOD} {route}` for HTTP (e.g. `GET /users/:id`), or `{EventType} {route}` for other event types |
| **Kind** | `INTERNAL` |
| **Attributes** | See [controller attributes](#controller-attributes) below |
| **Metrics** | Event duration histogram is recorded when this span ends |
For HTTP events, the root span is typically created by the OpenTelemetry HTTP instrumentation (`@opentelemetry/instrumentation-http`). The `SpanInjector` attaches to it rather than creating a duplicate.
For non-HTTP events (CLI, Workflow), the `SpanInjector` creates a span named `"{EventType} Event"` (e.g. `CLI Event`, `WF Event`).
### Interceptors:before
Wraps the entire "before" phase of all interceptors.
| Property | Value |
|----------|-------|
| **Name** | `Interceptors:before` |
| **Kind** | `INTERNAL` |
| **Contains** | One `Interceptor:{Name}` child span per interceptor |
### Interceptor:{Name}
Created for each individual interceptor's execution. `{Name}` is the interceptor class name (e.g. `Interceptor:AuthGuard`, `Interceptor:LoggingInterceptor`).
| Property | Value |
|----------|-------|
| **Name** | `Interceptor:{ClassName}` |
| **Kind** | `INTERNAL` |
| **Attribute** | `moost.interceptor.stage` — `'before'`, `'after'`, or `'onError'` |
| **Contains** | `Arguments:resolve` child span if the interceptor method has parameters |
The same interceptor may appear in both `Interceptors:before` and `Interceptors:after`, distinguished by the `moost.interceptor.stage` attribute.
### Arguments:resolve
Created when handler or interceptor parameters are resolved through the [pipes pipeline](/moost/pipes/).
| Property | Value |
|----------|-------|
| **Name** | `Arguments:resolve` |
| **Kind** | `INTERNAL` |
This span appears:
- Inside an `Interceptor:{Name}` span when the interceptor's `@Before()`, `@After()`, or `@OnError()` method has decorator-injected parameters (e.g. `@Param()`, `@Body()`, custom resolvers)
- As a direct child of `Event:start` for the handler method's parameter resolution
### Handler:{path}
Wraps the actual handler method execution.
| Property | Value |
|----------|-------|
| **Name** | `Handler:{targetPath}` (e.g. `Handler:/users/:id`) |
| **Kind** | `INTERNAL` |
| **Attributes** | `moost.handler` — method name, `moost.controller` — controller class name |
### Interceptors:after
Wraps the "after" phase (or "onError" phase if the handler threw).
| Property | Value |
|----------|-------|
| **Name** | `Interceptors:after` |
| **Kind** | `INTERNAL` |
| **Contains** | One `Interceptor:{Name}` child span per interceptor with after/error hooks |
## Controller attributes
When a controller and handler are resolved, the `SpanInjector` sets these attributes on the root event span:
| Attribute | Description | Example |
|-----------|-------------|---------|
| `moost.controller` | Controller class name | `UsersController` |
| `moost.handler` | Handler method name | `getUser` |
| `moost.handler_description` | From `@Description()` decorator | `'Fetch user by ID'` |
| `moost.handler_label` | From `@Label()` decorator | `'Get User'` |
| `moost.handler_id` | From `@Id()` decorator | `'users.get'` |
| `moost.route` | Resolved route path | `/users/:id` |
| `moost.event_type` | Event type | `HTTP`, `CLI`, `WF` |
| `moost.ignore` | Whether span is filtered | `true` / `false` |
## Span naming
The root span is renamed after the controller is resolved to include meaningful route information:
- **HTTP events:** `{HTTP_METHOD} {route}` (e.g. `GET /users/:id`)
- **Other events:** `{EventType} {route}` (e.g. `CLI users list`, `WF process-order`)
- **Unresolved routes:** `{EventType} ` when no route was matched
## Error handling
When an error occurs at any lifecycle phase:
1. The exception is **recorded** on the active span (`span.recordException(error)`)
2. The span status is set to `ERROR` with the error message
3. If the handler returns an `Error` instance (rather than throwing), it is also recorded as an exception
## Skipped spans
The following events do **not** produce spans:
- **`init` events** — Application initialization is not traced
- **`WF_STEP` method** — Workflow steps are skipped to prevent interference with the parent `WF_FLOW` span
- **`__SYSTEM__` method** — Internal system handlers (e.g. 404 fallback controllers) are skipped
- **Handlers with `@OtelIgnoreSpan()`** — The lifecycle phase spans (`Interceptors:before`, `Handler:...`, etc.) are skipped entirely; the callback runs without wrapping
## Example trace
Here is what a trace looks like in Jaeger for a `GET /hello/world` request with a `LoggingInterceptor`:
```
GET /hello/world 12.5ms
├─ Interceptors:before 0.8ms
│ └─ Interceptor:LoggingInterceptor 0.3ms stage=before
│ └─ Arguments:resolve 0.1ms
├─ Arguments:resolve 0.1ms
├─ Handler:/hello/:name 0.2ms handler=hello controller=App
└─ Interceptors:after 0.4ms
└─ Interceptor:LoggingInterceptor 0.2ms stage=after
└─ Arguments:resolve 0.1ms
```
Custom span attributes added via `customSpanAttr()` appear on the root event span alongside the controller attributes.
---
URL: "moost.org/swagger"
LLMS_URL: "moost.org/swagger.md"
---
# @moostjs/swagger
`@moostjs/swagger` generates an [OpenAPI 3](https://spec.openapis.org/oas/v3.0.3) document from your Moost controllers and serves a Swagger UI. It reads controller metadata, argument resolvers (`@Param`, `@Query`, `@Header`, `@Body`), and Atscript-annotated DTOs to produce accurate schemas with minimal manual annotation.
## Installation
```bash
npm install @moostjs/swagger swagger-ui-dist
```
`swagger-ui-dist` provides the static UI assets. It is a peer dependency — install it alongside the main package.
## Quick start
Register `SwaggerController` like any other Moost controller:
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { SwaggerController } from '@moostjs/swagger'
const app = new Moost()
app.adapter(new MoostHttp())
app.registerControllers(SwaggerController)
await app.init()
```
Open to see the Swagger UI. The JSON spec is available at `/api-docs/spec.json` and YAML at `/api-docs/spec.yaml`.
### Adding a controller
```ts
import { Controller, Description } from 'moost'
import { Body, Get, Param, Post, Query } from '@moostjs/event-http'
import { SwaggerTag } from '@moostjs/swagger'
import { CreateUserDto, UserDto } from './types/User.as'
@SwaggerTag('Users')
@Controller('users')
export class UsersController {
@Get(':id')
@Description('Fetch a user by ID')
find(@Param('id') id: string, @Query('expand') expand?: string): UserDto {
// ...
}
@Post()
create(@Body() dto: CreateUserDto): UserDto {
// ...
}
}
```
The generator automatically detects:
- **Path parameters** from `@Param('id')` and the `:id` route segment
- **Query parameters** from `@Query('expand')`
- **Request body** from `@Body()` with the `CreateUserDto` type
- **Response schemas** from return types (when using Atscript types with `toJsonSchema()`)
### Atscript DTOs
Atscript `.as` files provide type-safe schemas that the generator reads via `toJsonSchema()`:
```as
// types/User.as
@label "Create User"
export interface CreateUserDto {
@label "Display name"
name: string
@expect.pattern "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "u", "Invalid email"
email: string
roles?: string[]
}
@label "User"
export interface UserDto {
id: string
name: string
email: string
roles: string[]
}
```
Types are registered as named components in `#/components/schemas/` and reused via `$ref` throughout the spec. Types that expose `toExampleData()` have their examples populated automatically.
> Visit [atscript.moost.org](https://atscript.moost.org/) for full Atscript syntax, CLI usage, and IDE tooling.
## How it works
```
Decorators & metadata ──> mapToSwaggerSpec() ──> OpenAPI 3.0/3.1 JSON
@Controller, @Get, served by
@Body, @Query, reads controller overview, SwaggerController
@SwaggerTag, ... resolves schemas, builds at /api-docs/
paths + components
```
1. **Metadata collection** — Moost decorators and `@moostjs/swagger` decorators store metadata on controllers and handlers via the `Mate` system.
2. **Spec mapping** — `mapToSwaggerSpec()` iterates all registered controllers, resolves types to JSON Schema, and builds the OpenAPI document.
3. **Serving** — `SwaggerController` caches the spec and serves it alongside the Swagger UI assets.
## What to read next
| Page | What you'll learn |
|------|-------------------|
| [Configuration](/swagger/configuration) | Customize title, version, servers, tags, OpenAPI version, and CORS |
| [Operations](/swagger/operations) | Tags, descriptions, operationId, deprecated, and external docs |
| [Responses](/swagger/responses) | Status codes, content types, headers, examples, and return type inference |
| [Request Body](/swagger/request-body) | Body schemas, content types, and discriminated unions |
| [Parameters](/swagger/parameters) | Path, query, and header params — auto-inferred and manual |
| [Schemas & Types](/swagger/schemas) | How types become JSON Schema components |
| [Security](/swagger/security) | Auth scheme auto-discovery and security decorators |
| [Links & Callbacks](/swagger/links-callbacks) | Response links and webhook documentation |
| [Serving the UI](/swagger/serving-ui) | Endpoints, mount path, YAML, and programmatic access |
---
URL: "moost.org/swagger/configuration.html"
LLMS_URL: "moost.org/swagger/configuration.md"
---
# Configuration
`SwaggerController` accepts an options object that controls the generated OpenAPI document and the serving behaviour. All fields are optional.
## Basic setup
```ts
import { SwaggerController } from '@moostjs/swagger'
import { createProvideRegistry } from '@prostojs/infact'
const swagger = new SwaggerController({
title: 'My API',
description: 'REST API for the dashboard application',
version: '2.1.0',
})
app.setProvideRegistry(
createProvideRegistry([SwaggerController, () => swagger])
)
app.registerControllers(SwaggerController)
```
Without a provide registry, `SwaggerController` uses default values (`title: 'Moost API'`, `version: '1.0.0'`).
## Options reference
### Info fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `title` | `string` | `'Moost API'` | API title shown in the Swagger UI header |
| `description` | `string` | — | Markdown-supported description |
| `version` | `string` | `'1.0.0'` | API version |
| `contact` | `{ name?, url?, email? }` | — | Contact information |
| `license` | `{ name, url? }` | — | License details |
| `termsOfService` | `string` | — | URL to terms of service |
```ts
new SwaggerController({
title: 'Billing API',
version: '2025.1',
contact: { name: 'Platform Team', email: 'platform@example.com' },
license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' },
termsOfService: 'https://example.com/tos',
})
```
### Servers
Define server URLs that appear in the Swagger UI "Servers" dropdown:
```ts
new SwaggerController({
title: 'My API',
servers: [
{ url: 'https://api.example.com', description: 'Production' },
{ url: 'https://staging.example.com', description: 'Staging' },
{ url: 'http://localhost:3000', description: 'Local' },
],
})
```
### Tags
Tags applied via `@SwaggerTag()` on endpoints are auto-collected into the top-level `tags` array. Pass `tags` in options to add descriptions or control display order:
```ts
new SwaggerController({
title: 'My API',
tags: [
{ name: 'Users', description: 'User management endpoints' },
{ name: 'Billing', description: 'Subscription and payment APIs' },
],
})
```
Manual tags appear first (preserving order), followed by any additional tags discovered on endpoints. If a tag name appears in both places, the manual entry wins — so you can provide descriptions for auto-discovered tags.
Tags can also link to external documentation:
```ts
tags: [
{
name: 'Users',
description: 'User management',
externalDocs: { url: 'https://wiki.example.com/users', description: 'User API guide' },
},
]
```
### External documentation
Add a top-level external docs link to the spec:
```ts
new SwaggerController({
title: 'My API',
externalDocs: {
url: 'https://wiki.example.com/api',
description: 'Full API guide',
},
})
```
This appears as a link in the Swagger UI header. For per-operation external docs, see [`@SwaggerExternalDocs`](/swagger/operations#external-documentation).
### OpenAPI version
By default the spec uses OpenAPI 3.0.0. Set `openapiVersion: '3.1'` to generate an OpenAPI 3.1.0 document:
```ts
new SwaggerController({
title: 'My API',
openapiVersion: '3.1',
})
```
The main difference: nullable types use `type: ['string', 'null']` (3.1) instead of `nullable: true` (3.0). The transformation is applied automatically.
### CORS
Enable CORS headers on all swagger endpoints (spec, UI assets):
```ts
// Allow any origin
new SwaggerController({ title: 'My API', cors: true })
// Allow a specific origin
new SwaggerController({ title: 'My API', cors: 'https://docs.example.com' })
```
This is useful when hosting the spec on a different domain from your documentation portal or SDK generator.
### Security schemes
Security schemes can be defined manually in options or auto-discovered from `@Authenticate()` guards. See the [Security](/swagger/security) page for details.
```ts
new SwaggerController({
title: 'My API',
securitySchemes: {
oauth2: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'https://auth.example.com/authorize',
scopes: { 'read:users': 'Read users', 'write:users': 'Write users' },
},
},
},
},
security: [{ oauth2: ['read:users'] }],
})
```
## Full example
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { SwaggerController } from '@moostjs/swagger'
import { createProvideRegistry } from '@prostojs/infact'
const swagger = new SwaggerController({
title: 'Acme API',
description: 'Backend services for the Acme platform',
version: '3.0.0',
contact: { name: 'Backend Team', email: 'backend@acme.com' },
license: { name: 'MIT' },
servers: [
{ url: 'https://api.acme.com', description: 'Production' },
{ url: 'https://staging-api.acme.com', description: 'Staging' },
],
tags: [
{ name: 'Users', description: 'User management' },
{ name: 'Products', description: 'Product catalog' },
{ name: 'Orders', description: 'Order processing' },
],
externalDocs: { url: 'https://docs.acme.com', description: 'Developer portal' },
openapiVersion: '3.1',
cors: true,
})
const app = new Moost()
app.adapter(new MoostHttp())
app.setProvideRegistry(
createProvideRegistry([SwaggerController, () => swagger])
)
app.registerControllers(
SwaggerController,
UsersController,
ProductsController,
OrdersController,
)
await app.init()
```
## Using `mapToSwaggerSpec()` directly
If you don't need the UI and just want the spec object (for SDK generation, CI validation, etc.), call `mapToSwaggerSpec()` directly:
```ts
import { mapToSwaggerSpec } from '@moostjs/swagger'
const spec = mapToSwaggerSpec(app.getControllersOverview(), {
title: 'My API',
version: '2.0.0',
})
// Write to file, feed to openapi-generator, etc.
```
The function accepts the same options as `SwaggerController` and returns a plain object matching the OpenAPI 3.0 / 3.1 structure.
---
URL: "moost.org/swagger/links-callbacks.html"
LLMS_URL: "moost.org/swagger/links-callbacks.md"
---
# Links & Callbacks
These decorators document advanced OpenAPI features: response-driven navigation (links) and webhook delivery (callbacks). Both are optional — most APIs don't need them.
## Response links
OpenAPI [links](https://spec.openapis.org/oas/v3.1.0#link-object) describe how values from a response can be used as input to another operation. This is useful for HATEOAS-style navigation in the generated spec.
### Basic usage
```ts
import { SwaggerLink } from '@moostjs/swagger'
@SwaggerLink('GetUser', {
operationId: 'getUser',
parameters: { userId: '$response.body#/id' },
})
@SwaggerResponse(201, UserDto)
@Post()
create(@Body() dto: CreateUserDto) {
// The 201 response includes a link: "use the id from this response to call getUser"
}
```
### Target modes
A link must reference a target operation. Three modes are supported:
**By `operationId` string** — references any operation by its `operationId`:
```ts
@SwaggerLink('GetUser', {
operationId: 'getUser',
parameters: { userId: '$response.body#/id' },
})
```
**By `handler` reference** — type-safe, resolved to the actual `operationId` at mapping time:
```ts
@SwaggerLink('GetUser', {
handler: [UserController, 'getUser'],
parameters: { userId: '$response.body#/id' },
})
```
This is the recommended approach within a Moost application. If the target handler is renamed or moved, TypeScript flags the reference at compile time.
**By `operationRef`** — JSON pointer for cross-document or non-standard references:
```ts
@SwaggerLink('ExternalOp', {
operationRef: '#/paths/~1users~1{id}/get',
parameters: { id: '$response.body#/userId' },
})
```
### Multiple links
Stack multiple `@SwaggerLink` decorators to add several links to the same response:
```ts
@SwaggerLink('GetUser', {
handler: [UserController, 'getUser'],
parameters: { userId: '$response.body#/id' },
})
@SwaggerLink('ListOrders', {
handler: [OrderController, 'listByUser'],
parameters: { userId: '$response.body#/id' },
})
@SwaggerResponse(201, UserDto)
@Post()
create(@Body() dto: CreateUserDto) { /* ... */ }
```
### Status codes
By default, a link attaches to the default success status code for the HTTP method (200 for GET/PUT, 201 for POST, 204 for DELETE). Target a specific status code with the 3-argument form:
```ts
@SwaggerLink(201, 'GetCreated', {
operationId: 'getItem',
parameters: { id: '$response.body#/id' },
})
@SwaggerLink(200, 'GetUpdated', {
operationId: 'getItem',
parameters: { id: '$response.body#/id' },
})
```
### Options reference
| Field | Type | Description |
|-------|------|-------------|
| `operationId` | `string` | Target operation by operationId |
| `operationRef` | `string` | Target operation by JSON pointer |
| `handler` | `[Class, string]` | Target by controller class + method name (resolved at mapping time) |
| `parameters` | `Record` | Map of parameter names to [runtime expressions](https://spec.openapis.org/oas/v3.1.0#runtime-expressions) |
| `requestBody` | `string` | Runtime expression for the request body |
| `description` | `string` | Human-readable description of the link |
| `server` | `{ url, description? }` | Alternative server for the link target |
Exactly one of `operationId`, `operationRef`, or `handler` must be provided. If `handler` cannot be resolved (e.g., the target controller is not registered), the link is silently skipped.
---
## Callbacks (webhooks)
OpenAPI [callbacks](https://spec.openapis.org/oas/v3.1.0#callback-object) document requests your server sends to a client-provided URL — typically for webhook delivery after an event.
### Basic usage
```ts
import { SwaggerCallback } from '@moostjs/swagger'
@SwaggerCallback('onEvent', {
expression: '{$request.body#/callbackUrl}',
requestBody: EventPayloadDto,
description: 'Event notification sent to subscriber',
})
@Post('subscribe')
subscribe(@Body() dto: SubscribeDto) {
// Documents: "after subscribing, server POSTs EventPayloadDto to the callbackUrl"
}
```
The `expression` is an OpenAPI [runtime expression](https://spec.openapis.org/oas/v3.1.0#runtime-expressions) that resolves to the callback URL at runtime. The most common pattern is `'{$request.body#/callbackUrl}'` — a URL provided in the request body.
### Custom method, content type, and response
By default, callbacks use `POST`, `application/json`, and expect a `200 OK` response. Override any of these:
```ts
@SwaggerCallback('onUpdate', {
expression: '{$request.body#/webhookUrl}',
method: 'put',
contentType: 'text/plain',
responseStatus: 204,
responseDescription: 'Acknowledged',
})
@Post('subscribe')
subscribe(@Body() dto: SubscribeDto) { /* ... */ }
```
### Multiple callbacks
Stack multiple `@SwaggerCallback` decorators to document several webhooks on the same operation:
```ts
@SwaggerCallback('onCreated', {
expression: '{$request.body#/callbackUrl}',
requestBody: CreatedEventDto,
})
@SwaggerCallback('onUpdated', {
expression: '{$request.body#/callbackUrl}',
requestBody: UpdatedEventDto,
})
@Post('subscribe')
subscribe(@Body() dto: SubscribeDto) { /* ... */ }
```
### Options reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `expression` | `string` | *(required)* | Runtime expression resolving to the callback URL |
| `requestBody` | type/schema | — | Schema for the payload your server sends. Resolved via the [schema pipeline](/swagger/schemas) |
| `method` | `string` | `'post'` | HTTP method your server uses |
| `contentType` | `string` | `'application/json'` | Content type for the payload |
| `description` | `string` | — | Description for the callback operation |
| `responseStatus` | `number` | `200` | Expected response status from the receiver |
| `responseDescription` | `string` | `'OK'` | Description for the expected response |
---
URL: "moost.org/swagger/operations.html"
LLMS_URL: "moost.org/swagger/operations.md"
---
# Operations
Operation-level decorators control how individual endpoints appear in the generated spec and Swagger UI. Most are optional — the generator produces reasonable defaults from your controller and handler metadata.
## Tags
`@SwaggerTag` groups endpoints in the Swagger UI sidebar. Apply it to a controller (affects all handlers) or individual methods:
```ts
@SwaggerTag('Users')
@Controller('users')
export class UsersController {
@Get()
list() { /* tagged "Users" */ }
@SwaggerTag('Admin')
@Delete(':id')
remove(@Param('id') id: string) { /* tagged "Users" and "Admin" */ }
}
```
Multiple `@SwaggerTag` decorators stack — an endpoint can belong to several groups.
Tags are auto-collected into the top-level `tags` array. To add descriptions or control the display order, provide `tags` in the [configuration options](/swagger/configuration#tags).
## Descriptions and summaries
Two levels of text are available for each operation:
| Moost decorator | OpenAPI field | Where it appears |
|----------------|---------------|------------------|
| `@Label('...')` | `summary` | Short one-liner next to the endpoint path |
| `@Description('...')` | `description` | Expanded text below the summary (supports markdown) |
```ts
@Label('List all users')
@Description('Returns a paginated list of users. Supports filtering by role and status.')
@Get()
list(@Query('role') role?: string) { /* ... */ }
```
Both are standard Moost decorators — no swagger-specific import needed. If you need the swagger description to differ from the core one, use `@SwaggerDescription()` which takes priority.
## Operation ID
Every operation gets an auto-generated `operationId` based on the controller and method name (e.g., `UsersController_list`). Override it with:
```ts
// Swagger-specific override (highest priority)
@SwaggerOperationId('listUsers')
@Get()
list() { /* operationId: "listUsers" */ }
// Or use the core @Id decorator (fallback)
@Id('listUsers')
@Get()
list() { /* operationId: "listUsers" */ }
```
`@SwaggerOperationId` takes priority over `@Id()`, which takes priority over the auto-generated value. Operation IDs must be unique across the entire spec.
## Deprecated
Mark endpoints as deprecated in the spec. Swagger UI renders these with a strikethrough:
```ts
@SwaggerDeprecated()
@Get('v1/users')
listV1() { /* deprecated */ }
```
Applied to a controller, it marks all its handlers as deprecated:
```ts
@SwaggerDeprecated()
@Controller('v1')
export class V1Controller {
@Get('users')
list() { /* deprecated */ }
@Get('products')
products() { /* deprecated */ }
}
```
## Excluding endpoints
Hide a controller or handler from the generated spec entirely:
```ts
@SwaggerExclude()
@Controller('internal')
export class InternalController {
@Get('health')
health() { /* not in spec */ }
}
```
```ts
@Controller('users')
export class UsersController {
@Get()
list() { /* in spec */ }
@SwaggerExclude()
@Get('debug')
debug() { /* not in spec */ }
}
```
::: tip
`SwaggerController` itself is decorated with `@SwaggerExclude()`, so the `/api-docs` endpoints never appear in the generated spec.
:::
## External documentation
Link an operation to external docs (wiki pages, design documents, etc.):
```ts
@SwaggerExternalDocs('https://wiki.example.com/users/search', 'Search query syntax')
@Get('search')
search(@Query('q') q: string) { /* ... */ }
```
External docs are also supported at the [spec level and tag level](/swagger/configuration#external-documentation) via configuration options.
## Moost metadata integration
The generator recognizes several framework-level decorators from `moost`, so you rarely need swagger-specific annotations for basic metadata:
| Moost decorator | Swagger effect |
|----------------|----------------|
| `@Label()` | Operation `summary` and schema `title` |
| `@Description()` | Operation `description` and schema `description` |
| `@Id()` | Fallback for `operationId` |
| `@Optional()` / `@Required()` | Parameter requirement flags |
By reusing these generic decorators, your validation, documentation, and other modules stay aligned without duplicating annotations.
---
URL: "moost.org/swagger/parameters.html"
LLMS_URL: "moost.org/swagger/parameters.md"
---
# Parameters
The generator automatically creates OpenAPI parameters from Moost's argument resolvers. You rarely need to add swagger-specific annotations for parameters.
## Auto-inference
Parameters are detected from standard Moost decorators:
| Decorator | OpenAPI `in` | Notes |
|-----------|-------------|-------|
| `@Param('name')` | `path` | Always `required: true`, type from metadata |
| `@Query('name')` | `query` | Optional by default, type from metadata |
| `@Header('name')` | `header` | Type from metadata |
```ts
@Get(':id')
find(
@Param('id') id: string,
@Query('expand') expand?: string,
@Header('X-Request-Id') requestId?: string,
) { /* all three appear in the spec automatically */ }
```
The schema for each parameter is resolved from its TypeScript type. Primitives (`string`, `number`, `boolean`) map directly to JSON Schema types. Atscript types and classes with `toJsonSchema()` are resolved through the [schema pipeline](/swagger/schemas).
## Path parameters
Path parameters extracted from the route pattern (`:id`, `:slug`, etc.) are always marked as `required: true`:
```ts
@Get(':userId/posts/:postId')
findPost(
@Param('userId') userId: string,
@Param('postId') postId: number,
) { /* both required path params */ }
```
## Query parameters
### Simple types
A query parameter with a primitive type creates a single parameter:
```ts
@Get()
list(
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('search') search?: string,
) { /* three query params */ }
```
### Object expansion
When `@Query()` receives an object type (without a specific key), each property becomes a separate query parameter:
```ts
class ListFilters {
static toJsonSchema() {
return {
type: 'object',
properties: {
page: { type: 'number' },
limit: { type: 'number' },
role: { type: 'string' },
},
}
}
}
@Get()
list(@Query() filters: ListFilters) {
// Generates three query params: page, limit, role
}
```
This is useful for grouping related filters into a single DTO.
### Array parameters
Array-typed query params use the `explode` style (`?tag=a&tag=b`):
```ts
@Get()
list(@Query('tag') tags: string[]) {
// query param "tag" with schema { type: 'array', items: { type: 'string' } }
}
```
## Manual parameters with `@SwaggerParam`
When a parameter isn't resolved through standard Moost decorators, use `@SwaggerParam` to declare it manually:
```ts
import { SwaggerParam } from '@moostjs/swagger'
@SwaggerParam({
name: 'X-Api-Version',
in: 'header',
description: 'API version override',
required: false,
type: String,
})
@Get()
list() { /* ... */ }
```
### Options
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Parameter name |
| `in` | `'query' \| 'header' \| 'path' \| 'formData'` | Parameter location |
| `description` | `string` | Human-readable description |
| `required` | `boolean` | Whether the parameter is required |
| `type` | type/schema | Schema for the parameter value |
The `type` field accepts the same values as response schemas — primitives (`String`, `Number`), Atscript types, or inline JSON Schema objects.
### When to use `@SwaggerParam`
Use it for parameters that the generator can't infer automatically:
- Headers that aren't read via `@Header()`
- Query parameters added by middleware
- Cookie parameters
- Parameters with specific descriptions or constraints not expressed in the type
```ts
@SwaggerParam({
name: 'sort',
in: 'query',
description: 'Sort field and direction (e.g., "name:asc")',
required: false,
type: String,
})
@SwaggerParam({
name: 'X-Trace-Id',
in: 'header',
description: 'Distributed tracing identifier',
required: false,
type: String,
})
@Get()
list(@Query('page') page?: number) {
// "page" auto-inferred, "sort" and "X-Trace-Id" from @SwaggerParam
}
```
---
URL: "moost.org/swagger/request-body.html"
LLMS_URL: "moost.org/swagger/request-body.md"
---
# Request Body
The generator automatically documents request bodies from `@Body()` parameters. Use `@SwaggerRequestBody` when you need explicit control over the content type or schema.
## Auto-inference from `@Body()`
When a handler has a `@Body()` parameter, its type is used as the request body schema:
```ts
@Post()
create(@Body() dto: CreateUserDto) {
// Request body documented as CreateUserDto (application/json)
}
```
The content type is selected automatically based on the parameter type:
- **Objects and arrays** → `application/json`
- **Primitives and strings** → `text/plain`
## Explicit request body
Use `@SwaggerRequestBody` when you need to specify the content type or override the inferred schema:
```ts
import { SwaggerRequestBody } from '@moostjs/swagger'
@SwaggerRequestBody({
contentType: 'application/json',
response: CreateUserDto,
})
@Post()
create(@Body() dto: CreateUserDto) { /* ... */ }
```
### Multiple content types
Call `@SwaggerRequestBody` multiple times to document alternative content types:
```ts
@SwaggerRequestBody({
contentType: 'application/json',
response: CreateUserDto,
})
@SwaggerRequestBody({
contentType: 'application/xml',
response: { type: 'string' },
})
@Post()
create(@Body() body: unknown) { /* ... */ }
```
### Inline JSON Schema
Pass a plain JSON Schema object instead of a type:
```ts
@SwaggerRequestBody({
contentType: 'application/json',
response: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
},
})
@Post()
create(@Body() body: unknown) { /* ... */ }
```
## Discriminated unions
When a type's `toJsonSchema()` includes `$defs` (for discriminated unions, nested types, etc.), the generator hoists them into `#/components/schemas/` and rewrites `$ref` paths automatically.
For example, an Atscript discriminated union:
```as
export interface Dog {
petType: "dog"
name: string
breed: string
}
export interface Cat {
petType: "cat"
name: string
indoor: boolean
}
export type Pet = Dog | Cat
```
`Pet.toJsonSchema()` produces `oneOf` with `$ref: '#/$defs/Dog'` and `$ref: '#/$defs/Cat'` plus a `discriminator` block. The generator transforms this into:
- `#/components/schemas/Pet` — `oneOf` with `$ref` entries pointing to component schemas, `discriminator.mapping` rewritten
- `#/components/schemas/Dog` — hoisted from `$defs`
- `#/components/schemas/Cat` — hoisted from `$defs`
This works at any nesting depth — array items, object properties, etc. — so `Pet[]` correctly references the same `Dog` and `Cat` component schemas. See [Schemas & Types](/swagger/schemas#defs-hoisting) for more details.
## Form data
For `multipart/form-data` uploads, use `@SwaggerRequestBody` with the appropriate content type:
```ts
@SwaggerRequestBody({
contentType: 'multipart/form-data',
response: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
description: { type: 'string' },
},
},
})
@Post('upload')
upload(@Body() body: unknown) { /* ... */ }
```
---
URL: "moost.org/swagger/responses.html"
LLMS_URL: "moost.org/swagger/responses.md"
---
# Responses
`@SwaggerResponse` documents what an endpoint returns — the status code, schema, content type, headers, and examples. This is the decorator you'll use most often.
## Basic usage
Pass a type or configuration object. When no status code is given, the generator uses the default for the HTTP method:
| HTTP method | Default status |
|-------------|----------------|
| GET, PUT | 200 |
| POST | 201 |
| DELETE | 204 |
```ts
// Type shorthand — uses the method's default status code
@SwaggerResponse(UserDto)
@Get(':id')
find(@Param('id') id: string) { /* 200 response with UserDto schema */ }
// Explicit status code
@SwaggerResponse(201, UserDto)
@Post()
create(@Body() dto: CreateUserDto) { /* 201 response with UserDto schema */ }
// Error response
@SwaggerResponse(404, ErrorDto)
@Get(':id')
find(@Param('id') id: string) { /* 404 response with ErrorDto schema */ }
```
Multiple `@SwaggerResponse` decorators on the same handler combine into the operation's full response set:
```ts
@SwaggerResponse(UserDto)
@SwaggerResponse(404, ErrorDto)
@SwaggerResponse(403, ErrorDto)
@Get(':id')
find(@Param('id') id: string) { /* 200, 404, and 403 documented */ }
```
## Response descriptions
The OpenAPI spec requires a `description` for each response. The generator fills it automatically using HTTP reason phrases (`200` → `"OK"`, `404` → `"Not Found"`). Override with the `description` field:
```ts
@SwaggerResponse(409, {
description: 'Username already taken',
response: ConflictErrorDto,
})
@Post()
create(@Body() dto: CreateUserDto) { /* ... */ }
```
This is most useful for non-obvious status codes where the standard phrase doesn't explain _when_ the response occurs.
## Content types
By default, responses use `*/*` as the content type. Specify a content type explicitly:
```ts
@SwaggerResponse(200, {
contentType: 'application/json',
response: UserDto,
})
```
Multiple `@SwaggerResponse` calls with the same status code but different `contentType` values are merged into a single response with multiple media types:
```ts
@SwaggerResponse(200, { contentType: 'application/json', response: UserDto })
@SwaggerResponse(200, { contentType: 'application/xml', response: { type: 'string' } })
@Get(':id')
find(@Param('id') id: string) { /* 200 with JSON and XML variants */ }
```
## Response headers
Document response headers (pagination, rate-limiting, etc.) with the `headers` field:
```ts
@SwaggerResponse(200, {
response: UsersListDto,
headers: {
'X-Total-Count': {
type: Number,
description: 'Total number of items',
required: true,
},
'X-Rate-Limit': {
type: Number,
description: 'Requests remaining in current window',
example: 100,
},
},
})
@Get()
list() { /* ... */ }
```
Each header entry supports:
| Field | Type | Description |
|-------|------|-------------|
| `type` | type/schema | Schema for the header value (primitives, Atscript types, inline JSON Schema) |
| `description` | `string` | Human-readable description |
| `required` | `boolean` | Whether the header is always present |
| `example` | any | Example value |
## Examples
Examples can be attached in several ways. The priority order (highest first):
1. **Inline in `toJsonSchema()` output** — `example` field in the schema itself
2. **`@SwaggerExample` decorator** — explicit example on the handler
3. **`toExampleData()` method** — auto-generated from the type
### Positional argument
Pass the example as a third argument:
```ts
@SwaggerResponse(200, UserDto, { id: '1', name: 'Alice', email: 'alice@example.com' })
@Get(':id')
find(@Param('id') id: string) { /* ... */ }
```
### Config object
Include `example` in the configuration:
```ts
@SwaggerResponse(200, {
response: UserDto,
example: { id: '1', name: 'Alice', email: 'alice@example.com' },
})
```
### `@SwaggerExample` decorator
Attach an example to the handler's default response:
```ts
@SwaggerExample({ id: '1', name: 'Alice', email: 'alice@example.com' })
@SwaggerResponse(UserDto)
@Get(':id')
find(@Param('id') id: string) { /* ... */ }
```
### Auto-generated via `toExampleData()`
If a type exposes a static `toExampleData()` method, the generator calls it automatically:
```ts
class UserDto {
static toJsonSchema() {
return { type: 'object', properties: { name: { type: 'string' } } }
}
static toExampleData() {
return { name: 'Alice' }
}
}
```
Atscript types can generate `toExampleData()` automatically from `@meta.example` annotations. See [Schemas & Types](/swagger/schemas#auto-generated-examples) for details.
## Return type inference
When no `@SwaggerResponse` is declared for the success status code, the generator falls back to the method's return type (via TypeScript's `emitDecoratorMetadata`). This works for simple cases:
```ts
@Get(':id')
find(@Param('id') id: string): UserDto {
// 200 response schema inferred from `: UserDto` return type
}
```
::: warning TypeScript limitations
Return type inference relies on `emitDecoratorMetadata`, which has significant limitations:
- **Async methods** — `Promise` emits as `Promise`, losing the generic parameter.
- **Type aliases** — `type Users = User[]` emits as `Array`, losing the element type.
- **Generics** — Any generic wrapper (`Observable`, `Result`, etc.) loses its type argument.
For reliable response documentation, especially with async handlers, **always use `@SwaggerResponse` explicitly**.
:::
---
URL: "moost.org/swagger/schemas.html"
LLMS_URL: "moost.org/swagger/schemas.md"
---
# Schemas & Types
The generator converts TypeScript types into JSON Schema components for the OpenAPI spec. Understanding the resolution pipeline helps you get the most out of automatic schema generation.
## Resolution pipeline
When the generator encounters a type (from `@SwaggerResponse`, `@Body()`, `@SwaggerRequestBody`, etc.), it resolves it through the following pipeline:
| Input | Result |
|-------|--------|
| Class with `static toJsonSchema()` | Named component in `#/components/schemas/` |
| Instance with `toJsonSchema()` | Named component |
| Plain object (`{ type: 'string', ... }`) | Inline JSON Schema |
| `String`, `Number`, `Boolean` | `{ type: 'string' }`, `{ type: 'number' }`, `{ type: 'boolean' }` |
| `Date` | `{ type: 'string', format: 'date-time' }` |
| `Array` | `{ type: 'array' }` |
| `Object` | `{ type: 'object' }` |
| `[SomeType]` (array literal) | `{ type: 'array', items: }` |
| Literal string/number/boolean | `{ type, const: value }` |
| Zero-arg function | Invoked; the return value is resolved recursively |
## Atscript types
The recommended approach is to use [Atscript](https://atscript.moost.org/) `.as` files. The TypeScript plugin generates classes with `static toJsonSchema()`, so the swagger generator picks them up automatically:
```as
// types/User.as
@label "User"
export interface UserDto {
@label "User ID"
id: string
@label "Display name"
name: string
@label "Email address"
@expect.pattern "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "u", "Invalid email"
email: string
@label "Roles"
roles: string[]
}
```
This produces a `UserDto` class that the generator registers as `#/components/schemas/UserDto`. Everywhere you use `UserDto` — in responses, request bodies, parameters — it appears as `{ $ref: '#/components/schemas/UserDto' }`.
## Schema deduplication
The generator uses a `WeakMap` to track type references. The same class used in multiple places always produces a single component with multiple `$ref` pointers:
```ts
@SwaggerResponse(UserDto) // → $ref: '#/components/schemas/UserDto'
@Get(':id')
find() { /* ... */ }
@SwaggerResponse([UserDto]) // → { type: 'array', items: { $ref: '#/components/schemas/UserDto' } }
@Get()
list() { /* ... */ }
@SwaggerResponse(201, UserDto) // → same $ref, no duplicate
@Post()
create(@Body() dto: CreateUserDto) { /* ... */ }
```
When two different classes share the same name (e.g., `User` from different modules), the generator appends a numeric suffix: `User`, `User_1`, `User_2`.
## `$defs` hoisting
When a schema's `toJsonSchema()` output includes `$defs` (named sub-schemas referenced via `$ref: '#/$defs/Name'`), the generator automatically hoists them into `#/components/schemas/` and rewrites all `$ref` paths.
This commonly occurs with:
- **Discriminated unions** — Atscript unions with a discriminator property
- **Nested types** — Types that reference other named types internally
- **Recursive types** — Self-referencing schemas
Example: an Atscript discriminated union:
```as
export interface Dog {
petType: "dog"
name: string
breed: string
}
export interface Cat {
petType: "cat"
name: string
indoor: boolean
}
export type Pet = Dog | Cat
```
`Pet.toJsonSchema()` produces:
```json
{
"oneOf": [
{ "$ref": "#/$defs/Dog" },
{ "$ref": "#/$defs/Cat" }
],
"discriminator": {
"propertyName": "petType",
"mapping": { "dog": "#/$defs/Dog", "cat": "#/$defs/Cat" }
},
"$defs": {
"Dog": { "type": "object", "properties": { ... } },
"Cat": { "type": "object", "properties": { ... } }
}
}
```
The generator transforms this into:
- `#/components/schemas/Pet` — `oneOf` with rewritten `$ref`s
- `#/components/schemas/Dog` — hoisted from `$defs`
- `#/components/schemas/Cat` — hoisted from `$defs`
This works at any nesting depth — array items, object properties, etc. — so `Pet[]` correctly references the same component schemas.
## Auto-generated examples
### `toExampleData()`
If a type exposes a static `toExampleData()` method, the generator calls it to populate the `example` field in the component schema:
```ts
class UserDto {
static toJsonSchema() {
return {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
}
}
static toExampleData() {
return { name: 'Alice', email: 'alice@example.com' }
}
}
```
### Atscript `@meta.example`
Atscript types can generate `toExampleData()` automatically from `@meta.example` annotations. Enable it in your Atscript config:
```ts
// atscript.config.ts
import { defineConfig } from '@atscript/core'
import tsPlugin from '@atscript/typescript'
export default defineConfig({
entries: ['src/**/*.as'],
plugins: [
tsPlugin({
jsonSchema: 'bundle',
exampleData: true,
}),
],
})
```
With this enabled, every `.as` type with `@meta.example` annotations exposes `toExampleData()`, and the generator picks it up automatically. See the [Atscript docs](https://atscript.moost.org/packages/typescript/configuration#exampledata) for details.
### Example priority
When multiple sources provide an example, this priority order applies:
1. `example` field already present in `toJsonSchema()` output (highest)
2. `@SwaggerExample` decorator
3. `toExampleData()` method (lowest — auto-generation fallback)
## Metadata on schemas
Moost core decorators on DTOs are reflected in the generated component schemas:
| Decorator | Schema field |
|-----------|-------------|
| `@Label('...')` | `title` |
| `@Description('...')` | `description` |
| `@Id('...')` | `title` (fallback) |
```ts
@Label('User')
@Description('Represents a registered user in the system')
class UserDto {
static toJsonSchema() { /* ... */ }
}
```
Generated component:
```json
{
"UserDto": {
"title": "User",
"description": "Represents a registered user in the system",
"type": "object",
"properties": { ... }
}
}
```
## Array shorthand
Wrap a type in an array literal to create an array schema:
```ts
@SwaggerResponse([UserDto])
@Get()
list() {
// Schema: { type: 'array', items: { $ref: '#/components/schemas/UserDto' } }
}
```
This works anywhere a type is accepted — responses, request bodies, parameters.
## Lazy resolution
Pass a zero-argument function to defer schema resolution:
```ts
@SwaggerResponse(() => UserDto)
@Get(':id')
find() { /* ... */ }
```
This is useful for avoiding circular import issues — the function is invoked at mapping time, after all modules are loaded.
---
URL: "moost.org/swagger/security.html"
LLMS_URL: "moost.org/swagger/security.md"
---
# Security
`@moostjs/swagger` auto-discovers authentication requirements from `@Authenticate()` guards and generates `components.securitySchemes` and per-operation `security` arrays in the OpenAPI spec. No manual swagger configuration is needed for standard auth patterns.
## How it works
When you apply `@Authenticate(guard)` to a controller or handler, the decorator stores auth transport declarations in metadata. The swagger mapping engine reads these and:
1. Generates `securitySchemes` entries in `components`
2. Attaches `security` arrays to each protected operation
## Transport types
Four auth transports are supported, each mapping to an OpenAPI security scheme:
| Transport | Guard declaration | OpenAPI scheme | Auto-generated name |
|-----------|-------------------|----------------|---------------------|
| Bearer | `{ bearer: { format?: string } }` | `{ type: 'http', scheme: 'bearer' }` | `bearerAuth` |
| Basic | `{ basic: {} }` | `{ type: 'http', scheme: 'basic' }` | `basicAuth` |
| API key | `{ apiKey: { name, in } }` | `{ type: 'apiKey', name, in }` | `apiKeyAuth` |
| Cookie | `{ cookie: { name } }` | `{ type: 'apiKey', name, in: 'cookie' }` | `cookieAuth` |
## Example
```ts
import { Controller } from 'moost'
import { Authenticate, defineAuthGuard, Get, Param } from '@moostjs/event-http'
import { SwaggerPublic } from '@moostjs/swagger'
const jwtGuard = defineAuthGuard(
{ bearer: { format: 'JWT' } },
(transports) => {
// verify transports.bearer
},
)
@Authenticate(jwtGuard)
@Controller('users')
export class UsersController {
@SwaggerPublic()
@Get()
list() { /* public — no auth required */ }
@Get(':id')
find(@Param('id') id: string) { /* inherits bearer auth from controller */ }
}
```
Generated spec excerpt:
```json
{
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"paths": {
"/users": {
"get": { "security": [] }
},
"/users/{id}": {
"get": { "security": [{ "bearerAuth": [] }] }
}
}
}
```
## Security decorators
### `@SwaggerPublic()`
Marks a handler or controller as requiring no authentication in the docs. Emits `security: []` on affected operations:
```ts
@SwaggerPublic()
@Get('health')
health() { /* no lock icon in Swagger UI */ }
```
::: tip
`@SwaggerPublic()` only affects the swagger spec. It does not skip the auth guard at runtime. To bypass the guard itself, add a runtime check inside the guard.
:::
### `@SwaggerSecurity(schemeName, scopes?)`
Explicitly sets a security requirement with OR semantics — any one of the listed requirements suffices. Multiple `@SwaggerSecurity` calls add alternative requirements:
```ts
// Requires oauth2 with specific scopes
@SwaggerSecurity('oauth2', ['read:users', 'write:users'])
@Post()
create(@Body() dto: CreateUserDto) { /* ... */ }
// Accepts either bearer OR apiKey (OR semantics)
@SwaggerSecurity('bearerAuth')
@SwaggerSecurity('apiKeyAuth')
@Get()
list() { /* ... */ }
```
### `@SwaggerSecurityAll(requirement)`
Sets a security requirement with AND semantics — all listed schemes must be satisfied simultaneously:
```ts
// Requires BOTH bearer and API key
@SwaggerSecurityAll({ bearerAuth: [], apiKeyAuth: [] })
@Get('admin')
adminOnly() { /* ... */ }
```
## Resolution precedence
When determining the `security` array for an operation, the first match wins:
1. Handler `@SwaggerPublic()` → `security: []`
2. Handler `@SwaggerSecurity()` / `@SwaggerSecurityAll()` → explicit requirement
3. Handler `@Authenticate()` → converted from transport declaration
4. Controller `@SwaggerPublic()` → `security: []`
5. Controller `@SwaggerSecurity()` / `@SwaggerSecurityAll()` → explicit requirement
6. Controller `@Authenticate()` → converted from transport declaration
7. None → `security` omitted (inherits global default if set)
Handler-level decorators always take priority over controller-level ones. This lets you override controller-wide auth on specific endpoints.
## Global security
Set a default security requirement and manual security schemes through [configuration options](/swagger/configuration):
```ts
const swagger = new SwaggerController({
title: 'My API',
securitySchemes: {
oauth2: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'https://auth.example.com/authorize',
scopes: { 'read:users': 'Read users', 'write:users': 'Write users' },
},
},
},
},
security: [{ oauth2: ['read:users'] }],
})
```
Manually defined `securitySchemes` are merged with auto-discovered ones. The root `security` array serves as the default for operations that don't specify their own.
## Multiple auth schemes
### OR — accept any one of several methods
To accept **either** JWT **or** API key, declare both transports in a **single guard**. `extractTransports()` collects whichever credentials are present and only throws when none are found:
```ts
const flexibleGuard = defineAuthGuard(
{ bearer: { format: 'JWT' }, apiKey: { name: 'X-API-Key', in: 'header' } },
(transports) => {
if (transports.bearer) { /* verify JWT */ }
else if (transports.apiKey) { /* verify API key */ }
},
)
@Controller('data')
export class DataController {
@Authenticate(flexibleGuard)
@Get()
list() { /* ... */ }
}
```
The swagger mapping auto-discovers both transports and emits OR security (separate entries in the `security` array).
### AND — require all methods simultaneously
Stacking multiple `@Authenticate()` decorators registers separate interceptors that **all** run during the before phase. Every guard must pass:
```ts
const jwtGuard = defineAuthGuard(
{ bearer: { format: 'JWT' } },
(transports) => { /* verify JWT */ },
)
const apiKeyGuard = defineAuthGuard(
{ apiKey: { name: 'X-API-Key', in: 'header' } },
(transports) => { /* verify API key */ },
)
@Controller('data')
export class DataController {
// Requires BOTH JWT and API key
@Authenticate(jwtGuard)
@Authenticate(apiKeyGuard)
@Get('admin')
admin() { /* ... */ }
// Requires JWT only
@Authenticate(jwtGuard)
@Post()
create(@Body() dto: CreateDataDto) { /* ... */ }
}
```
::: warning Runtime vs docs
`@SwaggerSecurity` and `@SwaggerSecurityAll` only affect the OpenAPI spec — they do not add runtime guards. To enforce authentication, always use `@Authenticate()`. Use the swagger decorators when you need to override or fine-tune the generated security section (e.g. adding OAuth2 scopes).
:::
---
URL: "moost.org/swagger/serving-ui.html"
LLMS_URL: "moost.org/swagger/serving-ui.md"
---
# Serving the UI
`@moostjs/swagger` ships with a controller that serves both the generated OpenAPI document and the Swagger UI bundle. Once registered, the following endpoints become available.
## Endpoints
Default base path: `/api-docs`
| Endpoint | Description |
|----------|-------------|
| `/api-docs` | HTML page that bootstraps Swagger UI |
| `/api-docs/spec.json` | Cached OpenAPI 3 document (JSON) |
| `/api-docs/spec.yaml` | Cached OpenAPI 3 document (YAML) |
| `/api-docs/swagger-ui-*.{js,css}` | Static assets from `swagger-ui-dist` |
## Basic setup
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { SwaggerController } from '@moostjs/swagger'
const app = new Moost()
app.adapter(new MoostHttp())
app.registerControllers(SwaggerController)
await app.init()
```
Open to view the UI. The controller generates the spec on the first request and caches it for subsequent requests.
## Custom mount path
Use controller prefixes to change the base path:
```ts
app.registerControllers(['docs/swagger', SwaggerController])
await app.init()
```
The endpoints shift accordingly: `/docs/swagger`, `/docs/swagger/spec.json`, etc.
## Custom options
To set a title, version, CORS policy, or other options, instantiate `SwaggerController` yourself and expose it through the provide registry:
```ts
import { createProvideRegistry } from '@prostojs/infact'
import { SwaggerController } from '@moostjs/swagger'
const swagger = new SwaggerController({
title: 'Internal API',
description: 'Internal services for the dashboard',
version: '2025.1',
cors: 'https://docs.example.com',
servers: [
{ url: 'https://api.example.com', description: 'Production' },
{ url: 'https://staging.example.com', description: 'Staging' },
],
})
app.setProvideRegistry(
createProvideRegistry([SwaggerController, () => swagger])
)
app.registerControllers(SwaggerController)
```
See [Configuration](/swagger/configuration) for the full options reference.
## Multiple swagger instances
You can serve several swagger UIs from different paths — for example, side-by-side OpenAPI 3.0 and 3.1 specs, or separate public/internal docs.
Create distinct instances with their own options and register each under a different prefix:
```ts
import { createProvideRegistry } from '@prostojs/infact'
import { SwaggerController } from '@moostjs/swagger'
// OpenAPI 3.0 instance
class Swagger30 extends SwaggerController {}
const swagger30 = new Swagger30({
title: 'My API (3.0)',
openapiVersion: '3.0',
})
// OpenAPI 3.1 instance
class Swagger31 extends SwaggerController {}
const swagger31 = new Swagger31({
title: 'My API (3.1)',
openapiVersion: '3.1',
})
app.setProvideRegistry(
createProvideRegistry(
[Swagger30, () => swagger30],
[Swagger31, () => swagger31],
),
)
app.registerControllers(
['docs/v3.0', Swagger30],
['docs/v3.1', Swagger31],
)
```
This serves two independent UIs at `/docs/v3.0` and `/docs/v3.1`, each with its own cached spec. The same approach works for any split — public vs internal, versioned APIs, etc.
## YAML output
The spec is available in both JSON and YAML formats. YAML is converted from the JSON spec using a built-in zero-dependency converter. This is useful when:
- Sharing specs with frontend teams who prefer YAML
- Pasting into documentation that expects YAML
- Feeding into tools that accept YAML input
Access it at `/api-docs/spec.yaml` alongside the JSON version at `/api-docs/spec.json`.
## CORS
Enable CORS headers on all swagger endpoints:
```ts
// Allow any origin
new SwaggerController({ title: 'My API', cors: true })
// Allow a specific origin
new SwaggerController({ title: 'My API', cors: 'https://docs.example.com' })
```
This is useful when the spec is consumed by tools hosted on a different domain (documentation portals, SDK generators, API testing tools).
## Programmatic access
If you host the UI elsewhere or need the spec for code generation, call `mapToSwaggerSpec()` directly:
```ts
import { mapToSwaggerSpec } from '@moostjs/swagger'
const spec = mapToSwaggerSpec(app.getControllersOverview(), {
title: 'External Docs',
version: '2025.1',
})
```
The function returns a plain object matching the OpenAPI 3.0 / 3.1 structure. It accepts the same options as `SwaggerController`.
The generator reuses component schemas, so repeated types appear as `$ref`s automatically. You can serialize the result to JSON or YAML and serve it however you need.
---
URL: "moost.org/team.html"
LLMS_URL: "moost.org/team.md"
layout: "page"
title: "Meet the Team"
description: "The development of Moost is guided by a single software engineer."
---
Meet the Team
The development of Moost is driven by a single software developer.
---
URL: "moost.org/validation"
LLMS_URL: "moost.org/validation.md"
---
---
URL: "moost.org/validation/api.html"
LLMS_URL: "moost.org/validation/api.md"
---
# @atscript/moost-validator – API Reference
`@atscript/moost-validator` bridges Atscript-annotated types with the Moost pipeline system. It exposes a handful of helpers; everything else (advanced options, CLI usage, custom plugins) lives in the [Atscript documentation](https://atscript.moost.org/).
## Exports
| Export | Type | Summary |
| --- | --- | --- |
| `validatorPipe(opts?)` | `PipeFn` | Validates every value whose design type was generated by Atscript (`type.validator(opts).validate(value)`). |
| `UseValidatorPipe(opts?)` | Decorator | Syntactic sugar for attaching `validatorPipe()` to classes, methods, parameters, or properties. |
| `validationErrorTransform()` | InterceptorFn | Converts `ValidatorError` into `HttpError(400)` so clients see a structured validation response instead of `500`. |
| `UseValidationErrorTransform()` | Decorator | Shortcut for applying `validationErrorTransform()` at controller/handler level. |
All exports are tree-shakeable and have no runtime dependencies beyond Moost and Atscript.
## `validatorPipe(opts?)`
Attach once globally or locally when you need validation.
```ts
import { validatorPipe } from '@atscript/moost-validator'
app.applyGlobalPipes(validatorPipe())
```
`opts` accepts any subset of [`TValidatorOptions`](https://github.com/moostjs/atscript/blob/main/packages/typescript/src/validator.ts) (e.g. `{ partial: 'deep', errorLimit: 50 }`). Options are forwarded directly to Atscript’s validator.
## `UseValidatorPipe(opts?)`
```ts
import { Controller } from 'moost'
import { Body, Post } from '@moostjs/event-http'
import { UseValidatorPipe } from '@atscript/moost-validator'
import { UpdateUserDto } from './update-user.dto.as'
@Controller('users')
export class UsersController {
@Post()
@UseValidatorPipe({ partial: true })
async patch(@Body() dto: UpdateUserDto) {}
}
```
## `validationErrorTransform()`
Without this interceptor a failed validation becomes an unhandled error (`500`). With it, Moost responds with `400 Bad Request` and the validator details.
```ts
import { validationErrorTransform } from '@atscript/moost-validator'
app.applyGlobalInterceptors(validationErrorTransform())
```
Example response:
```json
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
{ "path": "email", "message": "Invalid email" }
]
}
```
## `UseValidationErrorTransform()`
```ts
import { UseValidationErrorTransform } from '@atscript/moost-validator'
import { CreateUserDto } from './create-user.dto.as'
@Post()
@UseValidationErrorTransform()
async create(@Body() dto: CreateUserDto) {}
```
Apply alongside `UseValidatorPipe()` when you want handler-level configuration instead of app-wide defaults.
---
Need more? See the [validation overview](./) for workflow guidance and the [Atscript docs](https://atscript.moost.org/) for DTO authoring, CLI usage, and plugin recipes.
---
URL: "moost.org/webapp"
LLMS_URL: "moost.org/webapp.md"
---
# Getting Started
Build an HTTP server with Moost in under a minute.
## Prerequisites
- Node.js 18 or higher
- npm, pnpm, or yarn
## Scaffold a project
```bash
npm create moost -- --http
```
Or with a project name:
```bash
npm create moost my-web-app -- --http
```
::: tip Other templates
`npm create moost -- --ssr` scaffolds a fullstack Vue + Moost app with SSR/SPA support. See [Vue + Moost (SSR)](./ssr).
Other options: `--ws` (WebSocket), `--cli` (CLI app). Run `npm create moost` without flags for interactive mode.
:::
The scaffolder creates:
```
my-web-app/
├── src/
│ ├── controllers/
│ │ └── app.controller.ts
│ └── main.ts
├── package.json
└── tsconfig.json
```
## What you get
**main.ts** — the entry point:
```ts
import { MoostHttp } from '@moostjs/event-http'
import { Moost } from 'moost'
import { AppController } from './controllers/app.controller'
const app = new Moost()
void app.adapter(new MoostHttp()).listen(3000, () => {
app.getLogger('moost-app').info('Up on port 3000')
})
void app
.registerControllers(AppController)
.init()
```
**app.controller.ts** — your first handler:
```ts
import { Get } from '@moostjs/event-http'
import { Controller, Param } from 'moost'
@Controller()
export class AppController {
@Get('hello/:name')
greet(@Param('name') name: string) {
return `Hello, ${name}!`
}
}
```
## Run it
```bash
npm install && npm run dev
```
Open [http://localhost:3000/hello/World](http://localhost:3000/hello/World) — you'll see `Hello, World!`.
## How it works
1. `new Moost()` creates the application instance
2. `new MoostHttp()` creates the HTTP adapter — the bridge between Moost and Node's HTTP server
3. `registerControllers()` tells Moost which classes contain route handlers
4. `init()` wires everything together — resolves dependencies, binds routes, prepares interceptors
The `@Get('hello/:name')` decorator registers the method as a `GET` handler. The `:name` segment becomes a route parameter, extracted by `@Param('name')`.
## What's next
- [Routing & Handlers](./routing) — HTTP methods, route patterns, and handler basics
- [Reading Request Data](./request) — extract query params, headers, cookies, and body
- [Controllers](./controllers) — organize handlers into logical groups
- [Vue + Moost (SSR)](./ssr) — fullstack Vue app with SSR, in-process API calls, and HMR
---
URL: "moost.org/webapp/auth.html"
LLMS_URL: "moost.org/webapp/auth.md"
---
# Authentication
Moost provides a declarative auth guard system that handles credential extraction from HTTP requests. Auth guards declare which **transports** they accept (bearer token, basic credentials, API key, cookie) and receive the extracted values in a handler function.
Auth guards also integrate with `@moostjs/swagger` for automatic security scheme documentation.
## Functional auth guards
Use `defineAuthGuard()` for straightforward, standalone guards:
```ts
import { defineAuthGuard, HttpError } from '@moostjs/event-http'
const jwtGuard = defineAuthGuard(
{ bearer: { format: 'JWT' } },
(transports) => {
const token = transports.bearer // raw token, no "Bearer " prefix
const user = verifyJwt(token)
if (!user) {
throw new HttpError(401, 'Invalid token')
}
},
)
```
The first argument declares the transports (what credentials to extract). The second is your verification logic.
### Applying auth guards
Use `@Authenticate` at the controller or handler level:
```ts
import { Authenticate, Get } from '@moostjs/event-http'
import { Controller } from 'moost'
@Authenticate(jwtGuard)
@Controller('users')
export class UsersController {
@Get('')
list() { /* all handlers require bearer auth */ }
@Get(':id')
find() { /* inherited from controller */ }
}
```
Apply at the handler level for specific endpoints:
```ts
@Controller('products')
export class ProductsController {
@Get('')
list() { /* public */ }
@Authenticate(jwtGuard)
@Post('')
create() { /* requires auth */ }
}
```
## Transport types
### Bearer token
Extracts from `Authorization: Bearer `:
```ts
defineAuthGuard(
{ bearer: { format: 'JWT', description: 'JWT access token' } },
(transports) => {
transports.bearer // string — raw token without "Bearer " prefix
},
)
```
### Basic authentication
Extracts from `Authorization: Basic `:
```ts
defineAuthGuard(
{ basic: { description: 'Admin credentials' } },
(transports) => {
transports.basic.username // string
transports.basic.password // string
},
)
```
### API key
Extracts a key from a header, query parameter, or cookie:
```ts
// From a header
defineAuthGuard(
{ apiKey: { name: 'X-API-Key', in: 'header' } },
(transports) => {
transports.apiKey // string
},
)
// From a query parameter
defineAuthGuard(
{ apiKey: { name: 'api_key', in: 'query' } },
(transports) => {
transports.apiKey // string
},
)
// From a cookie
defineAuthGuard(
{ apiKey: { name: 'api_key', in: 'cookie' } },
(transports) => {
transports.apiKey // string
},
)
```
### Cookie
Extracts a value from a named cookie:
```ts
defineAuthGuard(
{ cookie: { name: 'session_token' } },
(transports) => {
transports.cookie // string
},
)
```
## Class-based auth guards
Use the `AuthGuard` base class when you need dependency injection:
```ts
import { AuthGuard, HttpError } from '@moostjs/event-http'
import { Injectable } from 'moost'
@Injectable()
export class JwtGuard extends AuthGuard<{ bearer: { format: 'JWT' } }> {
static transports = { bearer: { format: 'JWT' } } as const
constructor(private userService: UserService) {}
handle(transports: { bearer: string }) {
const user = this.userService.verifyToken(transports.bearer)
if (!user) {
throw new HttpError(401, 'Invalid token')
}
}
}
```
Key points:
- Extend `AuthGuard` with your transport declaration as the generic
- Set `static transports` to match (read at runtime)
- Implement `handle(transports)` with your verification logic
- Use `@Injectable()` for constructor injection
Apply identically:
```ts
@Authenticate(JwtGuard)
@Controller('users')
export class UsersController { /* ... */ }
```
## Handler-level overrides
When `@Authenticate` is applied at both controller and handler level, the handler-level guard wins:
```ts
@Authenticate(apiKeyGuard)
@Controller('products')
export class ProductsController {
@Get('')
list() { /* uses apiKeyGuard */ }
@Authenticate(basicGuard)
@Post('')
create() { /* uses basicGuard instead */ }
}
```
## Credential extraction
When the guard runs, it inspects the request for the declared transports. If **none** of the declared credentials are present, it throws `HttpError(401, 'No authentication credentials provided')` before your handler is called.
## Swagger integration
Auth guards are automatically documented in the OpenAPI spec. Transport declarations map directly to OpenAPI security schemes. See [Swagger Security](/swagger/security) for fine-grained control with `@SwaggerPublic()` and `@SwaggerSecurity()`.
---
URL: "moost.org/webapp/benchmarks.html"
LLMS_URL: "moost.org/webapp/benchmarks.md"
---
# Benchmarks: Moost vs NestJS
This isn't a hello-world benchmark. We tested the **full HTTP lifecycle** — routing, header parsing, cookie jars with ~20 cookies, authentication, body parsing, and response serialization — all running through each framework's decorator-based DI layer.
Moost and NestJS share the same programming model (decorators, DI, controllers), but Moost is built on [Wooks](https://wooks.moost.org) instead of Express or Fastify. We wanted to know: **how much does the DI framework itself cost?**
::: details Benchmark setup and methodology
**Source code:** [prostojs/router-benchmark](https://github.com/prostojs/router-benchmark)
| Framework | HTTP Layer | Router | DI System |
|---|---|---|---|
| **Moost** | Wooks (@wooksjs/event-http) | @prostojs/router | @prostojs/mate + functional DI |
| **NestJS (Fastify)** | Fastify | find-my-way | @nestjs/core DI container |
| **NestJS (Express)** | Express | Express router | @nestjs/core DI container |
We used **autocannon** (100 connections, 10 pipelining) across **20 test scenarios** modeling a project management SaaS API with 21 routes. Traffic is weighted to reflect real-world patterns: public pages (20%), API calls with header auth (25%), browser routes with cookie auth (35%), and error responses (20%).
:::
## How Much Does DI Cost?
Both frameworks add overhead on top of their underlying HTTP layer. The difference is how much:
- **Moost** adds roughly **6%** on top of raw Wooks
- **NestJS** adds **10–12%** on top of raw Fastify/Express
For the underlying layer benchmarks, see [Wooks HTTP](https://wooks.moost.org/benchmarks/wooks-http) and [Router](https://wooks.moost.org/benchmarks/router).
## Overall Throughput
Across all 20 scenarios, Moost is about **10% faster** than NestJS on Fastify and **56% faster** than NestJS on Express.
## Public & Static Routes
On simple static routes, Moost and NestJS (Fastify) are neck and neck. Fastify has a slight edge on **login with set-cookie** thanks to its highly optimized cookie serialization.
## Header-Auth API Routes
Standard API routes with Bearer token auth are closely matched. Two interesting differences:
- Moost handles **auth failures faster** — Wooks short-circuits before allocating response resources
- NestJS (Fastify) handles **small POST bodies faster** — Fastify's request pipeline is tightly optimized for this path
## Cookie-Auth Browser Routes
This is where Moost pulls ahead clearly — about **14% faster** across cookie-authenticated routes. The reason: Wooks parses cookies **lazily**, only touching the ones your handler actually needs. NestJS parses the entire cookie jar upfront regardless.
## Error Responses & Edge Cases
Two results stand out:
- **404 responses** — Moost is about **40% faster** because NestJS routes 404s through its exception filter pipeline
- **Large body with bad auth** — Moost is over **3x faster** because Wooks skips body parsing entirely when authentication fails first. NestJS parses the full body before the guard rejects the request.
## Where Each Framework Shines
**Moost is faster when:**
- Requests carry lots of cookies (lazy parsing pays off)
- Requests fail early (404s, auth rejections)
- Large request bodies arrive with bad credentials
**NestJS (Fastify) is faster when:**
- Responses set cookies (Fastify's serialization is fast)
- Handling small POST/PUT bodies (Fastify's request pipeline is very tight)
## With Real Database Calls
In practice, your handlers do more than parse headers — they talk to databases. When we added simulated Redis calls (~1ms) to every handler, the picture changed:
With I/O in the mix, the gap between Moost and NestJS (Fastify) shrinks to just **~2.5%**. The DI framework overhead becomes a rounding error next to actual business logic.
::: info The bottom line
All three options are fast enough for production. Choose your framework for **developer experience and architecture**, not benchmarks. The real question is whether you prefer Moost's composable approach or NestJS's module system.
:::
## Why Moost
- **Familiar patterns** — same decorators and DI as NestJS, without the module boilerplate (`@Module()`, `providers` arrays, `forRoot()`)
- **Less DI overhead** — roughly half the cost of NestJS's DI layer
- **Built on Wooks** — lazy evaluation, typed context, composable functions
- **One architecture for everything** — HTTP, CLI, WebSocket, and Workflows
- **Powerful router** — regex constraints, multiple wildcards, optional params, case-insensitive matching via [@prostojs/router](https://github.com/niciam/router)
See also: [Router benchmark](https://wooks.moost.org/benchmarks/router) | [Wooks HTTP benchmark](https://wooks.moost.org/benchmarks/wooks-http)
---
**Source:** [prostojs/router-benchmark](https://github.com/prostojs/router-benchmark) | **Date:** February 2026 | **Method:** autocannon, 100 connections, 10 pipelining, 0.5s/test, 20 scenarios
---
URL: "moost.org/webapp/controllers.html"
LLMS_URL: "moost.org/webapp/controllers.md"
---
# Controllers
Controllers group related route handlers into a single class. They're the primary way to organize your Moost application.
## Defining a controller
Apply `@Controller()` to a class. The optional argument sets a path prefix for all handlers inside:
```ts
import { Get, Post, Delete, Body } from '@moostjs/event-http'
import { Controller, Param } from 'moost'
@Controller('users')
export class UserController {
@Get('')
list() { /* GET /users */ }
@Get(':id')
find(@Param('id') id: string) { /* GET /users/123 */ }
@Post('')
create(@Body() data: unknown) { /* POST /users */ }
@Delete(':id')
remove(@Param('id') id: string) { /* DELETE /users/123 */ }
}
```
Without a prefix, handlers register at the root:
```ts
@Controller()
export class RootController {
@Get('health')
health() { /* GET /health */ }
}
```
## Registering controllers
Controllers must be registered with the Moost instance. Two approaches:
### Using `registerControllers()`
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { UserController } from './user.controller'
import { ProductController } from './product.controller'
const app = new Moost()
void app.adapter(new MoostHttp()).listen(3000)
void app
.registerControllers(UserController, ProductController)
.init()
```
### Using `@ImportController`
When extending the Moost class, use the decorator:
```ts
import { Moost, ImportController } from 'moost'
import { UserController } from './user.controller'
@ImportController(UserController)
class MyServer extends Moost {
// handlers defined here also work
@Get('health')
health() { return 'ok' }
}
```
## Nested controllers
Controllers can import other controllers. The child inherits the parent's prefix:
```ts
import { Controller, ImportController, Param } from 'moost'
import { Get } from '@moostjs/event-http'
@Controller('user')
export class UserController {
@Get(':id')
getUser(@Param('id') id: string) {
return { id }
}
}
@Controller('api')
@ImportController(UserController)
export class ApiController {}
```
Result: `GET /api/user/:id`
You can nest multiple levels deep. Each level adds its prefix to the chain.
## Overriding prefixes
When importing a controller, you can replace its prefix:
```ts
@ImportController('people', UserController)
```
Now `UserController`'s routes use `/people/` instead of `/user/`.
## Reusing controllers
The same controller class can be imported multiple times with different prefixes and constructor arguments:
```ts
import { Controller, Param } from 'moost'
import { Get, Post, Delete, Body } from '@moostjs/event-http'
@Controller()
export class CrudController {
constructor(private collection: string) {}
@Get(':id')
find(@Param('id') id: string) {
return db.find(this.collection, id)
}
@Post('')
create(@Body() data: T) {
return db.insert(this.collection, data)
}
@Delete(':id')
remove(@Param('id') id: string) {
return db.remove(this.collection, id)
}
}
```
Import it twice with different configs:
```ts
@ImportController('users', () => new CrudController('users'))
@ImportController('products', () => new CrudController('products'))
class MyServer extends Moost {}
```
This gives you `GET /users/:id`, `POST /users`, `DELETE /users/:id` and the same set for `/products/`.
### Practical applications
- **API versioning** — `@ImportController('v1', LegacyApi)` alongside `@ImportController('v2', NewApi)`
- **Multi-tenant** — separate controller instances per tenant, each with its own prefix and config
- **Generic CRUD** — one controller class, many resource endpoints
## Global prefix
Set a prefix for the entire application:
```ts
const app = new Moost({ globalPrefix: 'api/v1' })
```
All routes will be prefixed with `/api/v1/`.
---
URL: "moost.org/webapp/di.html"
LLMS_URL: "moost.org/webapp/di.md"
---
# Dependency Injection
Moost uses dependency injection (DI) to manage object creation and wiring. Every controller is automatically injectable. This page covers scopes, injection patterns, and how scopes unlock property-level resolvers.
## Scopes
Every injectable class has a scope that controls its lifecycle:
| Scope | Behavior | Default |
|---|---|---|
| `SINGLETON` | One instance shared across all requests | Yes |
| `FOR_EVENT` | New instance created for each request | No |
### SINGLETON (default)
Controllers are singletons by default — one instance handles all requests. This is efficient and works well when handlers don't store per-request state:
```ts
@Controller('users')
export class UserController {
@Get(':id')
find(@Param('id') id: string) {
return { id } // id comes from parameters, not instance state
}
}
```
### FOR_EVENT
Use `@Injectable('FOR_EVENT')` when you need per-request state on the controller instance. This is particularly useful for property-level resolvers:
```ts
import { Get } from '@moostjs/event-http'
import { Controller, Injectable, Param } from 'moost'
@Injectable('FOR_EVENT')
@Controller('greet')
export class GreetController {
@Param('name')
name!: string
@Get(':name')
hello() {
return `Hello, ${this.name}!` // resolved per-request
}
}
```
Because a new `GreetController` is created for each request, `this.name` safely holds the current request's `:name` parameter.
## Property-level resolvers
In `FOR_EVENT` controllers, all resolver decorators (`@Param`, `@Query`, `@Header`, `@Cookie`, `@Body`, etc.) can be used on class properties instead of handler arguments:
```ts
import { Get, Post, Header, Body, Cookie } from '@moostjs/event-http'
import { Controller, Injectable, Param } from 'moost'
@Injectable('FOR_EVENT')
@Controller('api')
export class ApiController {
@Param('id')
id!: string
@Header('authorization')
authHeader!: string
@Cookie('session')
sessionToken!: string
@Get('items/:id')
getItem() {
// all properties are resolved before the handler runs
return { id: this.id, auth: this.authHeader }
}
}
```
## Response refs as properties
`FOR_EVENT` scope makes response refs especially elegant. Instead of using ref objects in handler parameters, bind them to properties:
```ts
import { Get, StatusRef, HeaderRef, CookieRef, CookieAttrsRef } from '@moostjs/event-http'
import type { TCookieAttributes } from '@moostjs/event-http'
import { Controller, Injectable } from 'moost'
@Injectable('FOR_EVENT')
@Controller()
export class ResponseController {
@StatusRef()
status = 200
@HeaderRef('x-custom')
customHeader = ''
@CookieRef('session')
sessionCookie = ''
@CookieAttrsRef('session')
sessionAttrs: TCookieAttributes = { httpOnly: true }
@Get('example')
example() {
this.status = 201
this.customHeader = 'some-value'
this.sessionCookie = 'new-token'
this.sessionAttrs = { maxAge: '1h', httpOnly: true }
return { ok: true }
}
}
```
Assigning to `this.status`, `this.customHeader`, etc. directly sets the response status, headers, and cookies. The initial values serve as defaults.
## Constructor injection
Any `@Injectable()` class can be injected through constructor parameters:
```ts
import { Injectable } from 'moost'
@Injectable()
export class UserService {
findById(id: string) {
return { id, name: 'John' }
}
}
```
```ts
import { Controller, Param } from 'moost'
import { Get } from '@moostjs/event-http'
import { UserService } from './user.service'
@Controller('users')
export class UserController {
constructor(private userService: UserService) {}
@Get(':id')
find(@Param('id') id: string) {
return this.userService.findById(id)
}
}
```
Moost resolves `UserService` automatically when creating `UserController`.
## @Provide and @Inject
For interface-based injection or when you need custom factories:
```ts
import { Provide, Inject, Controller } from 'moost'
// Register a factory for a token
@Provide('CONFIG', () => ({ apiUrl: 'https://api.example.com' }))
@Controller()
export class AppController {
constructor(@Inject('CONFIG') private config: { apiUrl: string }) {}
@Get('config')
getConfig() {
return this.config
}
}
```
## Scope rules
::: warning
A `SINGLETON` class **cannot** depend on a `FOR_EVENT` class. The singleton is created once, so it can't receive a new instance per request.
A `FOR_EVENT` class **can** depend on a `SINGLETON` class — it just receives the shared instance.
:::
```
SINGLETON → SINGLETON ✅
FOR_EVENT → SINGLETON ✅
FOR_EVENT → FOR_EVENT ✅
SINGLETON → FOR_EVENT ❌ will not work correctly
```
When in doubt, keep your controllers as singletons and use handler parameters for request-specific data. Switch to `FOR_EVENT` only when property-level resolvers or per-request state provide a clear benefit.
---
URL: "moost.org/webapp/fetch.html"
LLMS_URL: "moost.org/webapp/fetch.md"
outline: "deep"
---
# Programmatic Fetch (SSR)
`MoostHttp` provides `fetch()` and `request()` methods for invoking route handlers in-process with the full Moost pipeline — interceptors, DI, argument resolution, pipes, and validation — without a TCP round-trip.
## API
### `http.fetch(request)`
```ts
const response = await http.fetch(new Request('http://localhost/api/hello/world'))
// Response | null
```
Accepts a Web Standard `Request`. Returns a `Response` if a route matched, or `null` if no route exists.
### `http.request(input, init?)`
```ts
const response = await http.request('/api/hello/world')
const response = await http.request('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Alice' }),
headers: { 'content-type': 'application/json' },
})
// Response | null
```
Convenience wrapper — accepts a URL string (relative paths auto-prefixed with `http://localhost`), URL object, or Request, plus optional `RequestInit`.
### Header forwarding
When called from within an existing HTTP context (e.g. during SSR rendering), identity headers (`authorization`, `cookie`, `accept-language`, `x-forwarded-for`, `x-request-id`) are automatically forwarded from the calling request to the programmatic request. Explicitly set headers take priority.
## SSR Local Fetch
`enableLocalFetch` patches `globalThis.fetch` so that local requests are routed in-process through Moost. External URLs pass through to real HTTP. If no Moost route matches, the call falls back to the original `fetch`.
```ts
import { enableLocalFetch } from '@moostjs/event-http'
const teardown = enableLocalFetch(http)
// Local path → in-process via Moost
const res = await fetch('/api/hello/world')
// External URL → real HTTP
const res = await fetch('https://api.example.com/data')
// Restore original fetch
teardown()
```
### Automatic setup with Vite
When using `@moostjs/vite`, local fetch is enabled automatically (controlled by the `ssrFetch` option, default `true`). No manual setup needed — any `fetch('/api/...')` call during SSR goes through Moost in-process.
Set `ssrFetch: false` when running behind Nitro or another framework that manages fetch routing itself.
### Production usage
In production (without Vite), call `enableLocalFetch` manually in your server entry:
```ts
import { Moost } from 'moost'
import { MoostHttp, enableLocalFetch } from '@moostjs/event-http'
const app = new Moost()
const http = new MoostHttp()
enableLocalFetch(http)
app.adapter(http).listen(3000)
app.registerControllers(AppController)
await app.init()
```
## Use case: Universal SSR clients
With programmatic fetch, the same client interface works in both browser and server:
```ts
// Browser: real HTTP
const res = await fetch('/api/users')
// Server (SSR): in-process via Moost, same code
const res = await fetch('/api/users')
```
No separate client classes needed. The `enableLocalFetch` patch makes `fetch` transparent — server-side code calls the same API endpoints with the full pipeline, zero network overhead.
---
URL: "moost.org/webapp/guards.html"
LLMS_URL: "moost.org/webapp/guards.md"
---
# Guards & Authorization
Guards are interceptors that control access to handlers. While [Authentication](./auth) covers *who* the user is, guards decide *what* they're allowed to do.
::: tip
For credential extraction (bearer tokens, basic auth, API keys), use the dedicated [Authentication](./auth) system. The patterns below are for **authorization** — checking roles, permissions, and policies after the user is identified.
:::
## Basic guard
A guard is a before-interceptor with `GUARD` priority that throws on unauthorized access:
```ts
import { defineBeforeInterceptor, TInterceptorPriority } from 'moost'
import { HttpError } from '@moostjs/event-http'
const adminGuard = defineBeforeInterceptor(() => {
const user = getCurrentUser() // your auth logic
if (!user?.isAdmin) {
throw new HttpError(403, 'Admin access required')
}
}, TInterceptorPriority.GUARD)
```
Apply with `@Intercept`:
```ts
import { Intercept, Controller } from 'moost'
import { Get } from '@moostjs/event-http'
@Intercept(adminGuard)
@Controller('admin')
export class AdminController {
@Get('dashboard')
dashboard() { return { stats: '...' } }
}
```
## Guard decorator
Turn the interceptor into a reusable decorator:
```ts
import { Intercept } from 'moost'
const AdminOnly = Intercept(adminGuard)
@AdminOnly
@Controller('admin')
export class AdminController { /* ... */ }
```
## Role-based guard
Create a decorator factory that accepts a role:
```ts
import { Intercept, defineBeforeInterceptor, TInterceptorPriority } from 'moost'
import { HttpError } from '@moostjs/event-http'
const RequireRole = (role: string) => {
const fn = defineBeforeInterceptor(() => {
const user = getCurrentUser()
if (!user?.roles.includes(role)) {
throw new HttpError(403, `Role "${role}" required`)
}
}, TInterceptorPriority.GUARD)
return Intercept(fn)
}
```
Usage:
```ts
@RequireRole('editor')
@Controller('articles')
export class ArticleController {
@Get('')
list() { /* editors only */ }
@RequireRole('admin')
@Delete(':id')
remove() { /* admins only — overrides controller-level guard */ }
}
```
## Permission-based guard
Check specific permissions instead of roles:
```ts
const RequirePermission = (...permissions: string[]) => {
const fn = defineBeforeInterceptor(() => {
const user = getCurrentUser()
const missing = permissions.filter(p => !user?.permissions.includes(p))
if (missing.length > 0) {
throw new HttpError(403, `Missing permissions: ${missing.join(', ')}`)
}
}, TInterceptorPriority.GUARD)
return Intercept(fn)
}
@Controller('users')
export class UserController {
@RequirePermission('users:read')
@Get('')
list() { /* ... */ }
@RequirePermission('users:write')
@Post('')
create() { /* ... */ }
@RequirePermission('users:write', 'users:delete')
@Delete(':id')
remove() { /* ... */ }
}
```
## Class-based guard with DI
When your guard needs services from the DI container:
```ts
import { Interceptor, Before, TInterceptorPriority } from 'moost'
import { HttpError } from '@moostjs/event-http'
@Interceptor(TInterceptorPriority.GUARD)
export class PermissionGuard {
constructor(private permissionService: PermissionService) {}
@Before()
check() {
if (!this.permissionService.check()) {
throw new HttpError(403)
}
}
}
```
Apply it the same way:
```ts
@Intercept(PermissionGuard)
@Controller('admin')
export class AdminController { /* ... */ }
```
## Combining authentication + authorization
A common pattern is to stack auth and guard decorators:
```ts
@Authenticate(jwtGuard) // step 1: extract and verify credentials
@RequireRole('admin') // step 2: check authorization
@Controller('admin')
export class AdminController {
@Get('dashboard')
dashboard() { /* authenticated + admin role */ }
@RequirePermission('analytics:view')
@Get('analytics')
analytics() { /* authenticated + admin + analytics permission */ }
}
```
Authentication runs at `GUARD` priority. Custom guards also run at `GUARD` priority. Within the same priority, interceptors run in the order they're declared (outermost decorator first).
## Applying guards globally
Protect the entire application:
```ts
const app = new Moost()
app.applyGlobalInterceptors(adminGuard)
```
For per-controller or per-handler exceptions to a global guard, you'll need a guard that checks metadata or route info to decide whether to enforce. Consider the [Authentication](./auth) system with handler-level overrides for this pattern.
---
URL: "moost.org/webapp/interceptors.html"
LLMS_URL: "moost.org/webapp/interceptors.md"
---
# Interceptors
Interceptors hook into the request lifecycle to run logic before and after your handlers. They're the foundation for authentication, logging, error handling, and response transformation.
## Lifecycle
Every request passes through interceptors in this order:
1. **Before hooks** — run before the handler (can reply early)
2. **Handler execution** — your route handler runs
3. **After hooks** — run after the handler (can transform the response)
4. **Error hooks** — run if an error is thrown at any point
::: tip
See the [Event Lifecycle Diagram](/moost/event-lifecycle#diagram) for the full picture of how interceptors fit into the request flow.
:::
## Writing functional interceptors
### Before interceptor
Use `defineBeforeInterceptor` to run logic before the handler. Call `reply(value)` to skip the handler and respond immediately:
```ts
import { defineBeforeInterceptor, TInterceptorPriority } from 'moost'
const cacheInterceptor = defineBeforeInterceptor((reply) => {
const cached = cache.get(currentUrl())
if (cached) {
reply(cached) // skip handler, respond with cached value
}
}, TInterceptorPriority.INTERCEPTOR)
```
### After interceptor
Use `defineAfterInterceptor` to run logic after the handler succeeds. Receives the handler's response; call `reply(newValue)` to replace it:
```ts
import { defineAfterInterceptor, TInterceptorPriority } from 'moost'
const wrapper = defineAfterInterceptor((response, reply) => {
reply({ data: response, timestamp: Date.now() })
}, TInterceptorPriority.AFTER_ALL)
```
### Error interceptor
Use `defineErrorInterceptor` to handle errors. Receives the thrown error; call `reply(value)` to send a custom error response:
```ts
import { defineErrorInterceptor, TInterceptorPriority } from 'moost'
const errorFormatter = defineErrorInterceptor((error, reply) => {
reply({
error: error.message,
code: error.statusCode || 500,
})
}, TInterceptorPriority.CATCH_ERROR)
```
### Full interceptor
Combine multiple hooks in a single definition with `defineInterceptor`:
```ts
import { defineInterceptor, TInterceptorPriority } from 'moost'
import { useRequest } from '@wooksjs/event-http'
import { useLogger } from '@wooksjs/event-core'
const requestLogger = defineInterceptor({
after() {
const { url, method } = useRequest()
useLogger('http').log(`${method} ${url} OK`)
},
error(error) {
const { url, method } = useRequest()
useLogger('http').error(`${method} ${url} FAILED: ${error.message}`)
},
}, TInterceptorPriority.BEFORE_ALL)
```
## Priority levels
Interceptors run in priority order, lowest first:
| Priority | Value | Use case |
|---|---|---|
| `BEFORE_ALL` | 0 | Setup, timing, context |
| `BEFORE_GUARD` | 1 | Pre-auth logic |
| `GUARD` | 2 | Authentication & authorization |
| `AFTER_GUARD` | 3 | Post-auth setup |
| `INTERCEPTOR` | 4 | General purpose (default) |
| `CATCH_ERROR` | 5 | Error formatting |
| `AFTER_ALL` | 6 | Cleanup, final headers |
Each helper uses a sensible default:
| Helper | Default priority |
|---|---|
| `defineBeforeInterceptor` | `INTERCEPTOR` |
| `defineAfterInterceptor` | `AFTER_ALL` |
| `defineErrorInterceptor` | `CATCH_ERROR` |
| `defineInterceptor` | `INTERCEPTOR` |
## Applying interceptors
### Per handler
```ts
import { Intercept } from 'moost'
@Controller()
export class ExampleController {
@Get('secret')
@Intercept(guard)
secret() { /* protected */ }
@Get('public')
publicRoute() { /* not protected */ }
}
```
### Per controller
All handlers in the controller are intercepted:
```ts
@Intercept(guard)
@Controller('admin')
export class AdminController {
@Get('dashboard')
dashboard() { /* protected */ }
@Get('settings')
settings() { /* also protected */ }
}
```
### Globally
Affects every handler in the application:
```ts
const app = new Moost()
app.applyGlobalInterceptors(timing, errorFormatter)
```
## Turning interceptors into decorators
For cleaner syntax, wrap an interceptor with `Intercept` to create a reusable decorator:
```ts
import { Intercept, defineBeforeInterceptor, TInterceptorPriority } from 'moost'
const guardFn = defineBeforeInterceptor((reply) => {
if (!isAuthorized()) {
throw new HttpError(401)
}
}, TInterceptorPriority.GUARD)
// Create a decorator
const RequireAuth = Intercept(guardFn)
// Use it
@RequireAuth
@Controller('admin')
export class AdminController { /* ... */ }
```
### Parameterized decorators
Create decorator factories for configurable interceptors:
```ts
const RequireRole = (role: string) => {
const fn = defineBeforeInterceptor((reply) => {
if (!hasRole(role)) {
throw new HttpError(403, `Requires role: ${role}`)
}
}, TInterceptorPriority.GUARD)
return Intercept(fn)
}
@RequireRole('admin')
@Controller('admin')
export class AdminController { /* ... */ }
```
## Class-based interceptors
When you need dependency injection in your interceptor, use a class with the `@Interceptor` decorator:
```ts
import {
Interceptor, Before, After, OnError,
Overtake, Response, TInterceptorPriority,
} from 'moost'
import type { TOvertakeFn } from 'moost'
@Interceptor(TInterceptorPriority.GUARD)
export class AuthInterceptor {
constructor(private authService: AuthService) {}
@Before()
check() {
if (!this.authService.isAuthenticated()) {
throw new HttpError(401)
}
}
}
```
Use `@Overtake()` and `@Response()` parameter decorators to access the reply function and handler result:
```ts
@Interceptor(TInterceptorPriority.CATCH_ERROR)
export class ErrorHandler {
@OnError()
handle(@Response() error: Error, @Overtake() reply: TOvertakeFn) {
reply({ message: error.message })
}
}
```
Apply class-based interceptors the same way:
```ts
@Intercept(AuthInterceptor)
@Controller()
export class ProtectedController { /* ... */ }
// or globally
app.applyGlobalInterceptors(AuthInterceptor)
```
Moost creates the interceptor class through DI, so constructor dependencies are injected automatically.
## Practical example: request logger
```ts
import { Interceptor, After, OnError, TInterceptorPriority } from 'moost'
import { useRequest } from '@wooksjs/event-http'
import { useLogger } from '@wooksjs/event-core'
@Interceptor(TInterceptorPriority.BEFORE_ALL)
export class RequestLogger {
@After()
logSuccess() {
const { url, method } = useRequest()
useLogger('http').log(`${method} ${url} OK`)
}
@OnError()
logError(@Response() error: Error) {
const { url, method } = useRequest()
useLogger('http').error(`${method} ${url} FAILED: ${error.message}`)
}
}
```
## Practical example: error handler
```ts
import { defineErrorInterceptor, TInterceptorPriority } from 'moost'
import { useResponse } from '@wooksjs/event-http'
export const errorHandler = defineErrorInterceptor((error, reply) => {
const response = useResponse()
const status = error.statusCode || 500
response.status = status
reply({
error: error.message,
statusCode: status,
})
}, TInterceptorPriority.CATCH_ERROR)
```
---
URL: "moost.org/webapp/request.html"
LLMS_URL: "moost.org/webapp/request.md"
---
# Reading Request Data
Moost provides **resolver decorators** that extract values from the incoming request and inject them into your handler parameters. Each decorator corresponds to a different part of the HTTP request.
## Route parameters
Covered in detail on the [Routing & Handlers](./routing) page:
```ts
@Get('users/:id')
getUser(
@Param('id') id: string, // single parameter
@Params() all: { id: string }, // all parameters as object
) { /* ... */ }
```
## Query parameters
```ts
import { Get, Query } from '@moostjs/event-http'
import { Controller } from 'moost'
@Controller()
export class SearchController {
@Get('search')
search(
@Query('q') query: string, // single query param
@Query() params: Record, // all query params
) {
return { query, params }
}
}
```
```bash
curl "http://localhost:3000/search?q=moost&limit=10"
# { "query": "moost", "params": { "q": "moost", "limit": "10" } }
```
`@Query('name')` returns `undefined` if the parameter is missing. `@Query()` returns `undefined` (not an empty object) when there are no query parameters at all.
## Headers
```ts
import { Get, Header } from '@moostjs/event-http'
import { Controller } from 'moost'
@Controller()
export class ExampleController {
@Get('test')
test(@Header('content-type') contentType: string) {
return { contentType }
}
}
```
Header names are case-insensitive, matching standard HTTP behavior.
## Cookies
```ts
import { Get, Cookie } from '@moostjs/event-http'
import { Controller } from 'moost'
@Controller()
export class ExampleController {
@Get('profile')
profile(@Cookie('session') session: string) {
return { session }
}
}
```
## Request body
For `POST`, `PUT`, and `PATCH` requests, use `@Body()` to get the parsed body. Moost automatically detects JSON, form-encoded, and text content types.
```ts
import { Post, Body } from '@moostjs/event-http'
import { Controller } from 'moost'
@Controller('users')
export class UserController {
@Post('')
create(@Body() data: { name: string, email: string }) {
return { created: data }
}
}
```
For the raw body as a `Buffer`, use `@RawBody()`:
```ts
import { Post, RawBody } from '@moostjs/event-http'
@Post('upload')
upload(@RawBody() raw: Buffer) {
// process raw bytes
}
```
## Authorization header
The `@Authorization` decorator extracts specific parts of the `Authorization` header:
```ts
import { Get, Authorization } from '@moostjs/event-http'
@Get('profile')
profile(
@Authorization('bearer') token: string, // Bearer token (without "Bearer " prefix)
@Authorization('type') authType: string, // "Bearer", "Basic", etc.
) { /* ... */ }
@Get('login')
login(
@Authorization('username') user: string, // from Basic auth
@Authorization('password') pass: string, // from Basic auth
@Authorization('raw') credentials: string, // raw credentials string
) { /* ... */ }
```
::: tip
For production authentication, use the declarative [Authentication Guards](./auth) system instead of manually parsing the `Authorization` header.
:::
## URL and HTTP method
```ts
import { Get, Url, Method } from '@moostjs/event-http'
@Get('info')
info(
@Url() url: string, // e.g. "/info?page=1"
@Method() method: string, // e.g. "GET"
) {
return { url, method }
}
```
## Request ID
Every request gets a unique UUID, useful for logging and tracing:
```ts
import { Get, ReqId } from '@moostjs/event-http'
@Get('test')
test(@ReqId() requestId: string) {
return { requestId } // e.g. "a1b2c3d4-..."
}
```
## IP address
```ts
import { Get, Ip, IpList } from '@moostjs/event-http'
@Get('client')
client(
@Ip() ip: string, // direct client IP
@Ip({ trustProxy: true }) realIp: string, // considers x-forwarded-for
@IpList() allIps: string[], // full IP chain
) {
return { ip, realIp, allIps }
}
```
## Raw Node.js request
When you need direct access to the underlying `IncomingMessage`:
```ts
import { Get, Req } from '@moostjs/event-http'
import type { IncomingMessage } from 'http'
@Get('raw')
raw(@Req() request: IncomingMessage) {
return { httpVersion: request.httpVersion }
}
```
## Resolver decorators summary
| Decorator | Returns | Import from |
|---|---|---|
| `@Param(name)` | Route parameter value | `moost` |
| `@Params()` | All route params as object | `moost` |
| `@Query(name?)` | Query parameter(s) | `@moostjs/event-http` |
| `@Header(name)` | Request header value | `@moostjs/event-http` |
| `@Cookie(name)` | Cookie value | `@moostjs/event-http` |
| `@Body()` | Parsed request body | `@moostjs/event-http` |
| `@RawBody()` | Raw body as `Buffer` | `@moostjs/event-http` |
| `@Authorization(field)` | Auth header field | `@moostjs/event-http` |
| `@Url()` | Requested URL | `@moostjs/event-http` |
| `@Method()` | HTTP method string | `@moostjs/event-http` |
| `@ReqId()` | Request UUID | `@moostjs/event-http` |
| `@Ip(opts?)` | Client IP address | `@moostjs/event-http` |
| `@IpList()` | All IP addresses | `@moostjs/event-http` |
| `@Req()` | Raw `IncomingMessage` | `@moostjs/event-http` |
All resolver decorators can also be used as **property decorators** on `FOR_EVENT`-scoped controllers. See [Dependency Injection](./di) for details.
---
URL: "moost.org/webapp/resolvers.html"
LLMS_URL: "moost.org/webapp/resolvers.md"
---
# Custom Resolvers & Pipes
Moost's built-in decorators (`@Param`, `@Body`, `@Query`, etc.) cover common cases. When you need custom parameter resolution or value transformation, use `@Resolve` and `@Pipe`.
## Custom resolvers with @Resolve
The `@Resolve` decorator lets you create a parameter that resolves its value from any logic:
```ts
import { Resolve } from 'moost'
import { Get } from '@moostjs/event-http'
import { useRequest } from '@wooksjs/event-http'
function CurrentUser() {
return Resolve(() => {
const req = useRequest()
const token = req.headers.authorization?.replace('Bearer ', '')
return token ? verifyAndDecodeToken(token) : null
}, 'currentUser')
}
```
The second argument (`'currentUser'`) is a label used in error messages and swagger documentation.
Use it like any other resolver decorator:
```ts
@Controller('profile')
export class ProfileController {
@Get('')
getProfile(@CurrentUser() user: User) {
return user
}
}
```
### Resolver with access to metadata
The resolver function receives metadata about the decorated parameter and the decoration level:
```ts
function FromConfig(key: string) {
return Resolve((metas, level) => {
// metas.instance — the controller instance (if available)
// metas.key — the property/parameter name
// level — 'PARAM' for handler params, 'PROP' for class properties
return config.get(key)
}, `config:${key}`)
}
@Controller()
export class AppController {
@Get('settings')
settings(@FromConfig('app.name') appName: string) {
return { appName }
}
}
```
### Resolver on class properties
In `FOR_EVENT` controllers, custom resolvers work on properties too:
```ts
@Injectable('FOR_EVENT')
@Controller()
export class ApiController {
@CurrentUser()
user!: User
@Get('profile')
profile() {
return this.user
}
}
```
## Pipes
Pipes transform, validate, or modify resolved values as they flow through the parameter pipeline. Every parameter goes through this pipeline:
```
BEFORE_RESOLVE → RESOLVE → AFTER_RESOLVE → BEFORE_TRANSFORM → TRANSFORM
→ AFTER_TRANSFORM → BEFORE_VALIDATE → VALIDATE → AFTER_VALIDATE
```
The built-in resolvers (`@Param`, `@Body`, etc.) operate at `RESOLVE` priority. You can add pipes at any other stage.
### Writing a pipe
Use `definePipeFn` to create a pipe function:
```ts
import { definePipeFn, TPipePriority } from 'moost'
const toNumber = definePipeFn((value, metas, level) => {
if (typeof value === 'string') {
const num = Number(value)
if (Number.isNaN(num)) {
throw new Error(`"${metas.paramName}" must be a number`)
}
return num
}
return value
}, TPipePriority.TRANSFORM)
```
Arguments:
- `value` — the current value (output of previous pipe)
- `metas` — metadata about the parameter (name, type, decorators)
- `level` — decoration level (`'PARAM'` or `'PROP'`)
### Applying pipes
#### Per parameter
```ts
import { Pipe } from 'moost'
@Get('items/:id')
getItem(@Param('id') @Pipe(toNumber) id: number) {
return { id, type: typeof id } // { id: 42, type: 'number' }
}
```
#### Per handler
All parameters of the handler pass through the pipe:
```ts
@Pipe(toNumber)
@Get('range/:min/:max')
range(@Param('min') min: number, @Param('max') max: number) {
return { min, max }
}
```
#### Per controller
All parameters across all handlers:
```ts
@Pipe(toNumber)
@Controller('api')
export class ApiController { /* ... */ }
```
#### Globally
```ts
const app = new Moost()
app.applyGlobalPipes(toNumber)
```
### Validation pipe
A pipe at `VALIDATE` priority can reject invalid values:
```ts
const validatePositive = definePipeFn((value, metas) => {
if (typeof value === 'number' && value <= 0) {
throw new HttpError(400, `"${metas.paramName}" must be positive`)
}
return value
}, TPipePriority.VALIDATE)
```
Stack multiple pipes — they run in priority order:
```ts
@Get('items/:id')
getItem(
@Param('id')
@Pipe(toNumber)
@Pipe(validatePositive)
id: number,
) {
return { id }
}
```
### Turning pipes into decorators
For cleaner syntax, wrap a pipe:
```ts
const ToNumber = Pipe(toNumber)
const Positive = Pipe(validatePositive)
@Get('items/:id')
getItem(@Param('id') @ToNumber @Positive id: number) {
return { id }
}
```
Or combine multiple pipes into one decorator:
```ts
import { ApplyDecorators } from 'moost'
const PositiveInt = ApplyDecorators(Pipe(toNumber), Pipe(validatePositive))
@Get('items/:id')
getItem(@Param('id') @PositiveInt id: number) {
return { id }
}
```
## Pipe priority reference
| Priority | Value | Use case |
|---|---|---|
| `BEFORE_RESOLVE` | 0 | Pre-processing before resolution |
| `RESOLVE` | 1 | Value resolution (built-in) |
| `AFTER_RESOLVE` | 2 | Post-resolution adjustment |
| `BEFORE_TRANSFORM` | 3 | Pre-transform checks |
| `TRANSFORM` | 4 | Type coercion, formatting |
| `AFTER_TRANSFORM` | 5 | Post-transform verification |
| `BEFORE_VALIDATE` | 6 | Pre-validation setup |
| `VALIDATE` | 7 | Validation logic |
| `AFTER_VALIDATE` | 8 | Post-validation cleanup |
---
URL: "moost.org/webapp/response.html"
LLMS_URL: "moost.org/webapp/response.md"
---
# Responses & Errors
Moost converts your handler's return value into an HTTP response automatically. This page covers response formats, status codes, headers, cookies, error handling, and raw response access.
## Automatic response handling
The return value of a handler determines the response:
| Return type | Content-Type | Behavior |
|---|---|---|
| `string` | `text/plain` | Sent as plain text |
| object / array | `application/json` | JSON-serialized |
| `boolean` | `application/json` | JSON-serialized |
| `ReadableStream` | streamed | Piped to response |
| fetch `Response` | forwarded | Status, headers, and body preserved |
`content-length` is set automatically for non-streamed responses.
## Setting status codes
### Static — `@SetStatus`
When the status code is always the same:
```ts
import { Post, SetStatus } from '@moostjs/event-http'
import { Controller } from 'moost'
@Controller('users')
export class UserController {
@Post('')
@SetStatus(201)
create() {
return { created: true } // always responds with 201
}
}
```
By default, `@SetStatus` won't override a status code that was already set (e.g., by an error). Pass `{ force: true }` to always override:
```ts
@SetStatus(200, { force: true })
```
### Dynamic — `@StatusRef`
When the status code depends on runtime logic:
```ts
import { Get, StatusRef } from '@moostjs/event-http'
import type { TStatusRef } from '@moostjs/event-http'
@Get('process')
process(@StatusRef() status: TStatusRef) {
if (someCondition) {
status.value = 202 // Accepted
return { status: 'processing' }
}
return { status: 'done' } // default 200
}
```
## Setting headers
### Static — `@SetHeader`
```ts
import { Get, SetHeader } from '@moostjs/event-http'
@Get('test')
@SetHeader('x-powered-by', 'moost')
@SetHeader('cache-control', 'no-store')
test() {
return 'ok'
}
```
`@SetHeader` supports additional options:
```ts
// Only set this header when the response status is 400
@SetHeader('content-type', 'text/plain', { status: 400 })
// Override even if the header was already set
@SetHeader('x-custom', 'value', { force: true })
// Set on both success and error responses
@SetHeader('x-request-id', 'abc', { when: 'always' })
// Set only on error responses
@SetHeader('x-error', 'true', { when: 'error' })
```
### Dynamic — `@HeaderRef`
```ts
import { Get, HeaderRef } from '@moostjs/event-http'
import type { THeaderRef } from '@moostjs/event-http'
@Get('test')
test(@HeaderRef('x-custom') header: THeaderRef) {
header.value = `generated-${Date.now()}`
return 'ok'
}
```
## Setting cookies
### Static — `@SetCookie`
```ts
import { Get, SetCookie } from '@moostjs/event-http'
@Get('login')
@SetCookie('session', 'abc123', { maxAge: '1h', httpOnly: true })
login() {
return { ok: true }
}
```
::: info
`@SetCookie` won't overwrite a cookie that was already set in the response. This prevents accidental overwrites when multiple decorators or interceptors set the same cookie.
:::
### Dynamic — `@CookieRef`
```ts
import { Post, CookieRef, CookieAttrsRef } from '@moostjs/event-http'
import type { TCookieRef, TCookieAttributes } from '@moostjs/event-http'
@Post('login')
login(
@CookieRef('session') cookie: TCookieRef,
@CookieAttrsRef('session') attrs: { value: TCookieAttributes },
) {
const token = generateToken()
cookie.value = token
attrs.value = { maxAge: '1h', httpOnly: true, secure: true }
return { ok: true }
}
```
## Error handling
### Unhandled errors
Any uncaught exception becomes an HTTP 500 response:
```ts
@Get('fail')
fail() {
throw new Error('Something broke')
// → 500 Internal Server Error
}
```
### HttpError
Use `HttpError` to throw errors with specific HTTP status codes:
```ts
import { HttpError } from '@moostjs/event-http'
@Get('secret')
secret() {
throw new HttpError(403, 'Access denied')
// → 403 Forbidden with message "Access denied"
}
```
### Detailed error responses
Pass an object for structured error bodies:
```ts
throw new HttpError(422, {
message: 'Validation failed',
statusCode: 422,
errors: [
{ field: 'email', message: 'Invalid email format' },
{ field: 'age', message: 'Must be a positive number' },
],
})
```
The response format (JSON or HTML) adapts based on the request's `Accept` header.
### Error interceptors
For centralized error handling across multiple handlers, see [Interceptors](./interceptors).
## Raw response
For full control over the response, use `@Res()` to access the raw `ServerResponse`. When you do, the framework does **not** process the handler's return value — you're responsible for the entire response.
```ts
import { Get, Res } from '@moostjs/event-http'
import type { ServerResponse } from 'http'
@Get('raw')
raw(@Res() res: ServerResponse) {
res.writeHead(200, { 'content-type': 'text/plain' })
res.end('Manual response')
}
```
### Passthrough mode
If you need the raw response object but still want the framework to process your return value, use `{ passthrough: true }`:
```ts
@Get('hybrid')
hybrid(@Res({ passthrough: true }) res: ServerResponse) {
res.setHeader('x-custom', 'value')
return { data: 'processed by framework' } // framework handles this
}
```
---
URL: "moost.org/webapp/routing.html"
LLMS_URL: "moost.org/webapp/routing.md"
---
# Routing & Handlers
Every endpoint in Moost starts with an HTTP method decorator and a route path. This page covers how to define routes, use parameters, and control which methods your handlers respond to.
## HTTP method decorators
Moost provides a decorator for each HTTP method:
| Decorator | HTTP Method |
|-----------|-------------|
| `@Get(path?)` | GET |
| `@Post(path?)` | POST |
| `@Put(path?)` | PUT |
| `@Delete(path?)` | DELETE |
| `@Patch(path?)` | PATCH |
| `@All(path?)` | All methods |
All decorators are imported from `@moostjs/event-http`.
```ts
import { Get, Post, Put, Delete, Patch } from '@moostjs/event-http'
import { Controller, Param } from 'moost'
@Controller('users')
export class UserController {
@Get('')
list() {
return [] // GET /users
}
@Get(':id')
find(@Param('id') id: string) {
return { id } // GET /users/123
}
@Post('')
create() {
return { created: true } // POST /users
}
@Delete(':id')
remove(@Param('id') id: string) {
return { deleted: id } // DELETE /users/123
}
}
```
### Path defaults
The `path` argument is optional. When omitted, the method name becomes the path:
```ts
@Get()
getUsers() { /* GET /getUsers */ }
@Get('')
root() { /* GET / (root of controller) */ }
@Get('list')
getUsers() { /* GET /list */ }
```
::: tip
Use `@Get('')` (empty string) to handle the controller's root path. Omitting the argument entirely uses the method name as the path segment.
:::
### HEAD, OPTIONS, and custom methods
For HTTP methods without a convenience decorator, use `@HttpMethod`:
```ts
import { HttpMethod } from '@moostjs/event-http'
@HttpMethod('HEAD', 'health')
healthCheck() { /* HEAD /health */ }
@HttpMethod('OPTIONS', '')
cors() { /* OPTIONS / */ }
```
## Route parameters
Dynamic segments in a route are prefixed with `:`. Use `@Param('name')` to extract them.
```ts
@Get('users/:id')
getUser(@Param('id') id: string) {
return { id }
}
```
### Multiple parameters
Parameters can be separated by slashes or hyphens:
```ts
// Slash-separated: /flights/SFO/LAX
@Get('flights/:from/:to')
getFlight(
@Param('from') from: string,
@Param('to') to: string,
) { /* ... */ }
// Hyphen-separated: /dates/2024-01-15
@Get('dates/:year-:month-:day')
getDate(
@Param('year') year: string,
@Param('month') month: string,
@Param('day') day: string,
) { /* ... */ }
```
### Regex-constrained parameters
Restrict a parameter's shape with a regex pattern in parentheses:
```ts
// Only matches two-digit hours and minutes: /time/09h30m
@Get('time/:hours(\\d{2})h:minutes(\\d{2})m')
getTime(
@Param('hours') hours: string,
@Param('minutes') minutes: string,
) { /* ... */ }
```
### Repeated parameters (arrays)
When the same parameter name appears multiple times, the value becomes an array:
```ts
// /rgb/255/128/0 → color = ['255', '128', '0']
@Get('rgb/:color/:color/:color')
getRgb(@Param('color') color: string[]) { /* ... */ }
```
### All parameters at once
Use `@Params()` to get every route parameter as an object:
```ts
@Get('asset/:type/:type/:id')
getAsset(@Params() params: { type: string[], id: string }) {
return params
}
```
## Wildcards
An asterisk (`*`) matches zero or more characters within a path segment.
```ts
@Controller('static')
export class StaticController {
// Matches /static/anything/here
@Get('*')
handleAll(@Param('*') path: string) { /* ... */ }
// Matches /static/bundle.js, /static/vendor.js
@Get('*.js')
handleJS(@Param('*') path: string) { /* ... */ }
// Multiple wildcards → array
@Get('*/test/*')
handleTest(@Param('*') paths: string[]) { /* ... */ }
// Regex on wildcard: only digits
@Get('*(\\d+)')
handleNumbers(@Param('*') path: string) { /* ... */ }
}
```
## Query parameters
Query strings (`?key=value`) are not part of the route path. Use `@Query` to extract them:
```ts
import { Get, Query } from '@moostjs/event-http'
@Get('search')
search(
@Query('q') query: string,
@Query('page') page: string,
) {
return { query, page }
}
// GET /search?q=moost&page=2 → { query: 'moost', page: '2' }
```
Use `@Query()` without arguments to get all query parameters as an object:
```ts
@Get('search')
search(@Query() params: Record) {
return params
}
```
::: info
Query values are always strings. Use [pipes](./resolvers) to transform them to numbers or other types.
:::
## Handler return values
Whatever your handler returns becomes the response body. Moost automatically sets `content-type` and `content-length`:
| Return type | Content-Type |
|---|---|
| `string` | `text/plain` |
| object / array | `application/json` |
| `ReadableStream` | streamed |
| `Response` (fetch) | forwarded as-is |
```ts
@Get('text')
getText() {
return 'Hello!' // → text/plain
}
@Get('json')
getJson() {
return { message: 'Hello!' } // → application/json
}
```
Async handlers work the same way — return a promise:
```ts
@Get('data')
async getData() {
const data = await fetchFromDb()
return data
}
```
---
URL: "moost.org/webapp/security.html"
LLMS_URL: "moost.org/webapp/security.md"
---
# Body Limits & Security
Moost HTTP provides built-in protection against oversized request bodies and compression bombs. Limits can be configured at three levels, each overriding the previous.
## App-wide defaults
Pass `requestLimits` when creating the HTTP adapter:
```ts
import { MoostHttp } from '@moostjs/event-http'
import { Moost } from 'moost'
const app = new Moost()
void app.adapter(new MoostHttp({
requestLimits: {
maxCompressed: 5 * 1024 * 1024, // 5 MB compressed (default: 1 MB)
maxInflated: 50 * 1024 * 1024, // 50 MB decompressed (default: 10 MB)
maxRatio: 200, // compression ratio limit (default: 100)
readTimeoutMs: 30_000, // body read timeout (default: 10 000 ms)
},
})).listen(3000)
void app.init()
```
| Option | Default | Description |
|---|---|---|
| `maxCompressed` | 1 MB | Max compressed body size in bytes |
| `maxInflated` | 10 MB | Max decompressed body size in bytes |
| `maxRatio` | 100 | Max compression ratio (zip-bomb protection) |
| `readTimeoutMs` | 10 000 ms | Body read timeout |
## Per-handler overrides
Use decorators to set limits on specific handlers or controllers:
```ts
import {
BodySizeLimit,
CompressedBodySizeLimit,
BodyReadTimeoutMs,
Post,
} from '@moostjs/event-http'
import { Controller, Body } from 'moost'
@Controller('upload')
export class UploadController {
@Post('')
@BodySizeLimit(50 * 1024 * 1024) // 50 MB inflated
@CompressedBodySizeLimit(10 * 1024 * 1024) // 10 MB compressed
@BodyReadTimeoutMs(60_000) // 60 seconds
async upload(@Body() payload: unknown) {
return { received: true }
}
}
```
These decorators work on both handlers and controllers. When applied to a controller, all handlers inherit the limits.
## Global interceptors
For organization-wide policies applied through code (e.g., from a shared library), use the global interceptor helpers:
```ts
import {
globalBodySizeLimit,
globalCompressedBodySizeLimit,
globalBodyReadTimeoutMs,
} from '@moostjs/event-http'
import { Moost } from 'moost'
const app = new Moost()
app.applyGlobalInterceptors(
globalBodySizeLimit(20 * 1024 * 1024),
globalCompressedBodySizeLimit(5 * 1024 * 1024),
globalBodyReadTimeoutMs(15_000),
)
```
## Override precedence
Limits are applied in this order (later wins):
1. Constructor defaults (`requestLimits`)
2. Global interceptors (`app.applyGlobalInterceptors(...)`)
3. Controller-level decorators (`@BodySizeLimit` on class)
4. Handler-level decorators (`@BodySizeLimit` on method)
## 404 handling
When no route matches a request, Moost runs the response through the global interceptor chain before returning a `404 Resource Not Found` error. This means your global error handlers and logging interceptors will catch 404s too.
## Custom server integration
### Using getServerCb()
To integrate Moost HTTP with an existing Node.js server or serverless runtime, use `getServerCb()`:
```ts
import { MoostHttp } from '@moostjs/event-http'
import { Moost } from 'moost'
import { createServer } from 'http'
const app = new Moost()
const http = app.adapter(new MoostHttp())
void app.registerControllers(MyController).init()
// Use with a custom Node.js server
const server = createServer(http.getServerCb())
server.listen(3000)
```
This is useful for:
- **Serverless functions** — pass the callback to your serverless framework
- **Custom HTTPS** — create an `https.createServer` with your own certificates
- **Testing** — use with libraries like `supertest`
---
URL: "moost.org/webapp/ssr.html"
LLMS_URL: "moost.org/webapp/ssr.md"
outline: "deep"
---
# Vue + Moost (SSR)
Build a fullstack Vue + Moost application with server-side rendering, in-process API calls, and zero-config HMR.
## Scaffold a project
```bash
npm create moost -- --ssr
```
Or with a project name:
```bash
npm create moost my-app -- --ssr
```
The scaffolder asks whether to enable SSR (server-side rendering) or run as SPA only. Both modes share the same project structure — the difference is a single line in `vite.config.ts`.
## Project structure
```
my-app/
├── src/
│ ├── controllers/
│ │ └── api.controller.ts # Moost API routes
│ ├── pages/
│ │ ├── Home.vue # Landing page
│ │ └── About.vue # About page
│ ├── main.ts # Moost server entry
│ ├── app.ts # Vue app factory
│ ├── entry-client.ts # Client hydration
│ ├── entry-server.ts # SSR render function
│ └── router.ts # Vue Router config
├── public/ # Static assets
├── index.html # HTML template
├── vite.config.ts # Vite + Moost config
└── tsconfig.json
```
No `server.ts` needed — the plugin handles dev serving and auto-generates a production server during build.
## How it works
### vite.config.ts
```ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { moostVite } from '@moostjs/vite'
export default defineConfig({
server: { port: 3000 },
plugins: [
vue(),
moostVite({
entry: '/src/main.ts',
middleware: true,
prefix: '/api',
ssrEntry: '/src/entry-server.ts', // remove for SPA-only
}),
],
})
```
The [`moostVite`](/webapp/vite) plugin runs in **middleware mode** — Moost handles `/api/*` routes, everything else falls through to Vite.
### API routes
```ts
// src/controllers/api.controller.ts
import { Controller, Param } from 'moost'
import { Get } from '@moostjs/event-http'
@Controller('api')
export class ApiController {
@Get('hello/:name')
hello(@Param('name') name: string) {
return { message: `Hello, ${name}!`, timestamp: Date.now() }
}
}
```
### SSR data fetching
With [local fetch](/webapp/fetch) enabled (default), `fetch('/api/...')` calls Moost handlers in-process during SSR — no HTTP round-trip:
```vue
```
## SSR vs SPA
The only difference between SSR and SPA mode is the `ssrEntry` option in `vite.config.ts`:
| | SSR | SPA |
|---|---|---|
| `ssrEntry` | `'/src/entry-server.ts'` | not set |
| First paint | Server-rendered HTML | Empty shell, client renders |
| SEO | Full content in initial HTML | Requires client-side hydration |
| Data fetching | `onServerPrefetch` runs on server | `onMounted` runs on client |
To switch, add or remove `ssrEntry` in your `moostVite()` options.
## Running
### Development
```bash
npm run dev
```
Runs `vite` — opens at [http://localhost:3000](http://localhost:3000). Vue pages have HMR, Moost controllers hot-reload without restart. The plugin handles SSR rendering in dev automatically.
### Production build
```bash
npm run build
```
Produces client assets, SSR bundle, and server bundle to `dist/` in a single pass.
### Production start
```bash
npm run start
```
Runs `node dist/server/server.js` — production server with static file serving, API routes, and SSR rendering (or SPA fallback).
## Custom server entry
If you need custom middleware in production (compression, auth, logging), see the [Custom Server Entry](/webapp/vite#custom-server-entry) section in the Vite Plugin docs.
## Related
- [Vite Plugin](/webapp/vite) — backend mode, middleware mode, HMR, and all plugin options
- [Programmatic Fetch](/webapp/fetch) — in-process route invocation and SSR local fetch
- [Routing & Handlers](/webapp/routing) — HTTP methods, route patterns, controllers
---
URL: "moost.org/webapp/static.html"
LLMS_URL: "moost.org/webapp/static.md"
---
# Static Files & Proxy
Moost works with Wooks composables for serving static files and proxying requests.
## Static files
### Installation
```bash
npm install @wooksjs/http-static
```
### Basic usage
Use `serveFile()` to serve a file. It returns a readable stream and sets appropriate response headers, including caching and range request support.
```ts
import { serveFile } from '@wooksjs/http-static'
import { Get } from '@moostjs/event-http'
import { Controller, Param } from 'moost'
@Controller()
export class StaticController {
@Get('static/*')
serve(@Param('*') filePath: string) {
return serveFile(filePath, {
baseDir: './public',
cacheControl: { maxAge: '10m' },
})
}
}
```
### Options
| Option | Type | Description |
|---|---|---|
| `baseDir` | `string` | Base directory for resolving file paths |
| `cacheControl` | `string \| object` | Cache-Control header value |
| `expires` | `string` | Expires header value |
| `pragmaNoCache` | `boolean` | Add `Pragma: no-cache` header |
| `headers` | `object` | Additional response headers |
| `defaultExt` | `string` | Default extension when none is provided |
| `index` | `string` | Index file to serve from directories |
| `listDirectory` | `boolean` | List files if path is a directory |
## Proxy
### Installation
```bash
npm install @wooksjs/http-proxy
```
### Basic usage
Use `useProxy()` to create a proxy function that forwards requests to a target URL:
```ts
import { useProxy } from '@wooksjs/http-proxy'
import { Get } from '@moostjs/event-http'
import { Controller } from 'moost'
@Controller()
export class ProxyController {
@Get('api/*')
proxy() {
const proxy = useProxy()
return proxy('https://api.example.com/v1')
}
}
```
The proxy function forwards the request and returns a fetch `Response`.
### Filtering headers and cookies
Control what gets forwarded:
```ts
@Get('api/*')
proxy() {
const proxy = useProxy()
return proxy('https://api.example.com', {
reqHeaders: { block: ['referer', 'cookie'] },
reqCookies: { block: '*' },
})
}
```
### Modifying responses
The proxy returns a standard fetch `Response`, so you can modify it before returning:
```ts
@Get('api/data')
async proxyData() {
const proxy = useProxy()
const response = await proxy('https://api.example.com/data')
const data = await response.json()
return { ...data, proxied: true }
}
```
### Advanced options
```ts
proxy('https://target.com/path', {
method: 'GET', // override HTTP method
reqHeaders: { block: ['referer'] }, // block request headers
reqCookies: { allow: ['session'] }, // allow specific cookies
resHeaders: { overwrite: { 'x-proxy': 'moost' } }, // add response headers
resCookies: { allow: ['session'] }, // filter response cookies
debug: true, // log proxy details
})
```
---
URL: "moost.org/webapp/swagger.html"
LLMS_URL: "moost.org/webapp/swagger.md"
---
# Swagger / OpenAPI
`@moostjs/swagger` generates an [OpenAPI 3](https://spec.openapis.org/oas/v3.0.3) document from your Moost controllers and serves a Swagger UI — all driven by the decorators you already use.
## Quick setup
```bash
npm install @moostjs/swagger swagger-ui-dist
```
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { SwaggerController } from '@moostjs/swagger'
const app = new Moost()
app.adapter(new MoostHttp())
app.registerControllers(SwaggerController)
await app.init()
```
Open to see the Swagger UI.
The generator automatically picks up `@Controller`, `@Get`/`@Post`/…, `@Param`, `@Query`, `@Body`, return types, and Atscript DTOs — so most endpoints are documented without any extra annotations.
::: tip Full documentation
For configuration options, decorator reference, schema customization, security schemes, and more, see the **[dedicated Swagger docs section](/swagger/)**.
:::
---
URL: "moost.org/webapp/validation.html"
LLMS_URL: "moost.org/webapp/validation.md"
---
# Validation
Moost uses [Atscript](https://atscript.moost.org/) for request validation. Atscript extends TypeScript with annotations that produce runtime validators from your types — no `class-validator`, no duplicate schemas, one source of truth for types and validation rules.
::: tip Atscript documentation
For installation, compiler setup, annotation syntax, and all available options see the [Atscript docs](https://atscript.moost.org/) and the [`@atscript/moost-validator` package reference](https://atscript.moost.org/packages/moost-validator/).
:::
## Quick example
### 1. Define a DTO in Atscript
```atscript
// create-user.as
export interface CreateUserDto {
@expect.minLength 2
@expect.maxLength 100
name: string
email: string.email
@meta.sensitive
@expect.minLength 8
password: string
role?: 'admin' | 'user'
}
```
Atscript compiles this to a regular TypeScript module that also carries runtime metadata — validators, labels, constraints — all inferred from the annotations.
### 2. Use it in a handler
```ts
import { Controller } from 'moost'
import { Post, Body } from '@moostjs/event-http'
import {
UseValidatorPipe,
UseValidationErrorTransform,
} from '@atscript/moost-validator'
import type { CreateUserDto } from './create-user.as'
@UseValidatorPipe()
@UseValidationErrorTransform()
@Controller('users')
export class UsersController {
@Post()
create(@Body() dto: CreateUserDto) {
// dto is validated — name, email, password all satisfy the Atscript schema
return { created: dto.name }
}
}
```
That's it. When a request arrives with an invalid body, the validator pipe rejects it before your handler runs and returns a structured `400` response:
```json
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
{ "path": "email", "message": "Must be a valid email" },
{ "path": "password", "message": "Length must be >= 8" }
]
}
```
## Enabling validation globally
Instead of decorating each controller, register the pipe and interceptor once on the app:
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { validatorPipe, validationErrorTransform } from '@atscript/moost-validator'
const app = new Moost()
app.adapter(new MoostHttp())
app.applyGlobalPipes(validatorPipe())
app.applyGlobalInterceptors(validationErrorTransform())
// register controllers...
await app.init()
```
Now every handler parameter whose type was compiled by Atscript is validated automatically.
## Validator options
`validatorPipe(opts)` accepts any subset of Atscript's `TValidatorOptions`. For instance, to allow unknown properties and collect multiple errors:
```ts
app.applyGlobalPipes(
validatorPipe({
unknwonProps: 'ignore',
errorLimit: 50,
}),
)
```
## Why Atscript
| Approach | Schemas | Validation rules | Runtime cost |
|---|---|---|---|
| **class-validator + class-transformer** | Duplicate class + decorators | Imperative decorators | Reflection + transform |
| **Zod / Yup** | Separate schema object | Builder API | Schema parse |
| **Atscript** | Your TypeScript types *are* the schema | Annotations in the type file | Compiled validators |
Atscript eliminates the gap between the type you write and the validation that runs. One `.as` file produces the TypeScript type, the JSON Schema (for Swagger), and the runtime validator.
## Further reading
- [Atscript documentation](https://atscript.moost.org/) — language reference, CLI, IDE support
- [`@atscript/moost-validator`](https://atscript.moost.org/packages/moost-validator/) — pipe and interceptor API
- [Validation pipe deep dive](/moost/pipes/validate) — priority ordering, per-parameter config, non-HTTP usage
- [Custom resolvers & pipes](/webapp/resolvers) — writing your own transform and validation pipes
---
URL: "moost.org/webapp/vite.html"
LLMS_URL: "moost.org/webapp/vite.md"
outline: "deep"
---
# Vite Plugin
`@moostjs/vite` integrates Moost with Vite's dev server for hot module replacement, automatic adapter detection, and production build configuration.
## Quick Start
Scaffold a project with everything pre-configured:
```bash
npm create moost -- --http # API server
npm create moost -- --ssr # Vue + Moost fullstack (SSR/SPA)
```
Or add to an existing project:
```bash
npm install @moostjs/vite --save-dev
```
## Backend Mode (default)
For API servers where Moost handles all HTTP requests. Vite provides HMR and TypeScript/decorator transforms.
```ts
// vite.config.ts
import { defineConfig } from 'vite'
import { moostVite } from '@moostjs/vite'
export default defineConfig({
plugins: [
moostVite({
entry: './src/main.ts',
}),
],
})
```
Your entry file is a standard Moost app:
```ts
// src/main.ts
import { Moost, Param } from 'moost'
import { MoostHttp, Get } from '@moostjs/event-http'
class App extends Moost {
@Get('hello/:name')
hello(@Param('name') name: string) {
return { message: `Hello ${name}!` }
}
}
const app = new App()
const http = new MoostHttp()
app.adapter(http).listen(3000)
app.init()
```
Run `vite dev` to start, `vite build` for production.
## Middleware Mode
For fullstack apps where Vite serves the frontend (Vue, React, Svelte) and Moost handles API routes. Set `middleware: true` — Moost handles matching routes, everything else falls through to Vite.
```ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { moostVite } from '@moostjs/vite'
export default defineConfig({
plugins: [
vue(),
moostVite({
entry: './src/api/main.ts',
middleware: true,
prefix: '/api', // optional: skip Moost for non-API paths
}),
],
})
```
The entry file is a standard Moost app (same as backend mode). The `prefix` option is optional — it adds a fast-path filter so requests not matching the prefix skip Moost entirely.
## SSR Mode
Add `ssrEntry` to enable server-side rendering:
```ts
moostVite({
entry: '/src/main.ts',
middleware: true,
prefix: '/api',
ssrEntry: '/src/entry-server.ts',
})
```
`vite build` produces three bundles in a single pass:
- **client** — browser assets (`dist/client/`)
- **ssr** — server-side render function (`dist/server/ssr/`)
- **server** — production Node.js server (`dist/server/server.js`)
Omit `ssrEntry` for SPA mode — the production build still generates a server for static files and API routes, just without server-side rendering.
See [Vue + Moost (SSR)](/webapp/ssr) for the full guide.
## Custom Server Entry
By default, `vite build` auto-generates a minimal production server. If you need custom middleware (compression, auth, logging), provide your own server file:
```ts
moostVite({
entry: '/src/main.ts',
middleware: true,
prefix: '/api',
ssrEntry: '/src/entry-server.ts',
serverEntry: './server.ts',
})
```
Your `server.ts` uses `createSSRServer` from `@moostjs/vite/server`:
```ts
// server.ts
import { createSSRServer } from '@moostjs/vite/server'
const app = await createSSRServer()
// app.use(compression())
await app.listen()
```
`createSSRServer` handles dev/prod automatically.
::: tip
`serverEntry` is only used during `vite build`. In dev, the plugin handles SSR/SPA fallback directly. If you need custom middleware in dev too, run `tsx server.ts` instead of `vite`.
:::
## Hot Module Replacement
Both modes support full HMR for controllers. When a file changes, the plugin invalidates affected modules, cleans up stale DI instances (cascading to dependants), and re-initializes the app. The next request uses updated code — no restart needed.
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `entry` | `string` | — | Application entry file (required) |
| `port` | `number` | `3000` | Dev server port |
| `host` | `string` | `'localhost'` | Dev server host |
| `outDir` | `string` | `'dist'` | Build output directory |
| `format` | `'cjs' \| 'esm'` | `'esm'` | Output module format |
| `sourcemap` | `boolean` | `true` | Generate source maps |
| `externals` | `boolean \| object` | `true` | External dependencies config |
| `onEject` | `function` | — | Hook to control DI instance ejection during HMR |
| `ssrFetch` | `boolean` | `true` | Enable [SSR local fetch](/webapp/fetch) interception |
| `middleware` | `boolean` | `false` | Run Moost as Connect middleware |
| `prefix` | `string` | — | URL prefix filter for middleware mode |
| `ssrEntry` | `string` | — | Vue/React SSR entry module (e.g. `'/src/entry-server.ts'`) |
| `ssrOutlet` | `string` | `''` | HTML placeholder for SSR-rendered content |
| `ssrState` | `string` | `''` | HTML placeholder for SSR state transfer script |
| `serverEntry` | `string` | — | Custom production server entry file (e.g. `'./server.ts'`) |
::: tip
Options `port`, `host`, `outDir`, `format`, `sourcemap`, and `externals` are only used in backend mode. In middleware mode, your `vite.config.ts` controls build and server configuration.
:::
---
URL: "moost.org/wf"
LLMS_URL: "moost.org/wf.md"
---
# Getting Started
This guide walks you through creating your first Moost workflow — a simple order processing pipeline with three linear steps.
## Installation
Add the workflow adapter to your Moost project:
::: code-group
```bash [npm]
npm install @moostjs/event-wf
```
```bash [pnpm]
pnpm add @moostjs/event-wf
```
:::
### AI Agent Skill (Optional)
Install the unified Moost AI skill for context-aware assistance in AI coding agents (Claude Code, Cursor, etc.):
```bash
npx skills add moostjs/moostjs
```
## Define a Workflow Controller
Create a controller with a workflow entry point and its steps:
```ts
// src/order.controller.ts
import { Controller, Injectable } from 'moost'
import {
Step,
Workflow,
WorkflowParam,
WorkflowSchema,
} from '@moostjs/event-wf'
interface TOrderContext {
orderId: string
validated: boolean
charged: boolean
shipped: boolean
}
@Injectable('FOR_EVENT')
@Controller()
export class OrderController {
@WorkflowParam('context')
ctx!: TOrderContext
@Workflow('process-order') // [!code focus]
@WorkflowSchema([ // [!code focus]
'validate', // [!code focus]
'charge', // [!code focus]
'ship', // [!code focus]
]) // [!code focus]
processOrder() {}
@Step('validate') // [!code focus]
validate() {
console.log(`Validating order ${this.ctx.orderId}`)
this.ctx.validated = true
}
@Step('charge') // [!code focus]
charge() {
console.log(`Charging for order ${this.ctx.orderId}`)
this.ctx.charged = true
}
@Step('ship') // [!code focus]
ship() {
console.log(`Shipping order ${this.ctx.orderId}`)
this.ctx.shipped = true
}
}
```
A few things to notice:
- `@Workflow('process-order')` marks the method as a workflow entry point with the ID `process-order`
- `@WorkflowSchema` defines the step execution order — here it's a simple linear sequence
- `@Step('validate')` registers a method as a workflow step that can be referenced in schemas
- `@WorkflowParam('context')` injects the shared workflow context into a class property
- The entry point method body is empty — all logic lives in the steps
## Register the Adapter
Wire up the workflow adapter in your application entry point:
```ts
// src/main.ts
import { Moost } from 'moost'
import { MoostWf } from '@moostjs/event-wf'
import { OrderController } from './order.controller'
const app = new Moost()
const wf = new MoostWf() // [!code focus]
app.adapter(wf) // [!code focus]
app.registerControllers(OrderController)
await app.init()
// Start the workflow // [!code focus]
const result = await wf.start('process-order', { // [!code focus]
orderId: 'ORD-001', // [!code focus]
validated: false, // [!code focus]
charged: false, // [!code focus]
shipped: false, // [!code focus]
}) // [!code focus]
console.log(result.finished) // true
console.log(result.state.context)
// { orderId: 'ORD-001', validated: true, charged: true, shipped: true }
```
`wf.start()` takes a schema ID and an initial context object. It returns a `TFlowOutput` with the final state.
## Understanding the Output
Every `start()` or `resume()` call returns a `TFlowOutput` object:
```ts
{
state: {
schemaId: 'process-order',
context: { orderId: 'ORD-001', validated: true, charged: true, shipped: true },
indexes: [3],
},
finished: true, // workflow completed all steps
stepId: 'ship', // last executed step
interrupt: undefined, // not paused
inputRequired: undefined,
}
```
Key fields:
- **`finished`** — `true` if the workflow ran to completion
- **`state`** — serializable snapshot (schema ID, context, step position)
- **`interrupt`** — `true` if the workflow paused (waiting for input or after a retriable error)
- **`inputRequired`** — present when a step needs input before it can proceed
## What's Next?
You've built a linear workflow. From here, explore:
- [**Steps**](/wf/steps) — accessing context, input, and parametric paths
- [**Schemas & Flow Control**](/wf/schemas) — conditions, loops, and branching
- [**Pause & Resume**](/wf/pause-resume) — pausing for user input and resuming later
---
URL: "moost.org/wf/api.html"
LLMS_URL: "moost.org/wf/api.md"
---
# API Reference
Complete reference for all exports from `@moostjs/event-wf`.
[[toc]]
## Decorators
### `@Workflow(path?)`
Marks a controller method as a workflow entry point. Must be paired with `@WorkflowSchema`.
- **`path`** *(string, optional)* — Workflow identifier. Defaults to the method name. Can include route parameters (e.g., `'process/:type'`).
```ts
import { Workflow, WorkflowSchema } from '@moostjs/event-wf'
@Workflow('order-processing')
@WorkflowSchema(['validate', 'charge', 'ship'])
processOrder() {}
```
The effective schema ID combines the controller prefix and the workflow path. For `@Controller('admin')` + `@Workflow('order')`, the schema ID is `admin/order`.
### `@WorkflowSchema(schema)`
Attaches a workflow schema (step sequence) to a `@Workflow` method.
- **`T`** *(generic)* — Workflow context type. Provides type checking in condition functions.
- **`schema`** *(`TWorkflowSchema`)* — Array of step definitions.
```ts
@WorkflowSchema([
'step-a',
{ condition: (ctx) => ctx.ready, id: 'step-b' },
{ while: 'retries < 3', steps: ['attempt', 'increment'] },
])
```
See [Schemas & Flow Control](/wf/schemas) for full schema syntax.
### `@Step(path?)`
Marks a controller method as a workflow step handler.
- **`path`** *(string, optional)* — Step identifier used in schemas. Defaults to the method name. Can include route parameters (e.g., `'notify/:channel'`).
```ts
@Step('validate')
validate(@WorkflowParam('context') ctx: TOrderContext) {
ctx.validated = true
}
```
### `WorkflowParam(name)`
Parameter and property decorator that injects workflow runtime values.
- **`name`** — One of the following:
| Name | Type | Description |
|------|------|-------------|
| `'context'` | `T` | Shared workflow context object |
| `'input'` | `I \| undefined` | Input passed to `start()` or `resume()` |
| `'stepId'` | `string \| null` | Current step ID (`null` in entry point) |
| `'schemaId'` | `string` | Active workflow schema identifier |
| `'indexes'` | `number[] \| undefined` | Current position in nested schemas |
| `'resume'` | `boolean` | `true` when resuming, `false` on first run |
| `'state'` | `object` | Full state object from `useWfState()` |
```ts
// As method parameter:
@Step('process')
process(@WorkflowParam('context') ctx: TCtx, @WorkflowParam('input') input?: TInput) {}
// As class property (requires @Injectable('FOR_EVENT')):
@WorkflowParam('context')
ctx!: TMyContext
```
::: info
`WorkflowParam('resume')` returns a **boolean**, not a function. It indicates whether the current execution is a resume (`true`) or a fresh start (`false`). The resume *function* is available on the `TFlowOutput` object returned by `start()` or `resume()`.
:::
## MoostWf Class
`MoostWf` is the workflow adapter. Register it with `app.adapter(new MoostWf())`.
- **`T`** — Workflow context type
- **`IR`** — Input-required type (the type of `inputRequired` values)
### Constructor
```ts
new MoostWf(opts?: WooksWf | TWooksWfOptions, debug?: boolean)
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `opts` | `WooksWf \| TWooksWfOptions \| undefined` | Pre-existing WooksWf instance, configuration options, or `undefined` for defaults |
| `debug` | `boolean` | Enable error logging |
**`TWooksWfOptions` fields:**
| Field | Type | Description |
|-------|------|-------------|
| `onError` | `(e: Error) => void` | Global error handler |
| `onNotFound` | `TWooksHandler` | Handler for unregistered steps |
| `onUnknownFlow` | `(schemaId: string, raiseError: () => void) => unknown` | Handler for unknown workflow schemas |
| `logger` | `TConsoleBase` | Custom logger instance |
| `eventOptions` | `EventContextOptions` | Event context configuration |
| `router` | `TWooksOptions['router']` | Router configuration |
### `start(schemaId, initialContext, input?)`
Starts a new workflow execution.
```ts
start(
schemaId: string,
initialContext: T,
input?: I,
): Promise>
```
| Parameter | Description |
|-----------|-------------|
| `schemaId` | Workflow identifier (matching `@Workflow` path, including controller prefix) |
| `initialContext` | Initial context object passed to steps |
| `input` | Optional input for the first step |
```ts
const result = await wf.start('process-order', { orderId: '123', status: 'new' })
```
### `resume(state, input?)`
Resumes a paused workflow from a saved state.
```ts
resume(
state: { schemaId: string, context: T, indexes: number[] },
input?: I,
): Promise>
```
| Parameter | Description |
|-----------|-------------|
| `state` | State object from a previous `TFlowOutput.state` |
| `input` | Input for the paused step |
```ts
const resumed = await wf.resume(previousResult.state, { answer: 'yes' })
```
### `attachSpy(fn)`
Attaches a spy function to observe workflow execution. Returns a detach function.
```ts
attachSpy(fn: TWorkflowSpy): () => void
```
```ts
const detach = wf.attachSpy((event, eventOutput, flowOutput, ms) => {
console.log(event, flowOutput.stepId, ms)
})
detach() // stop observing
```
See [Spies & Observability](/wf/spies) for event types and usage patterns.
### `detachSpy(fn)`
Removes a previously attached spy function.
```ts
detachSpy(fn: TWorkflowSpy): void
```
### `handleOutlet(config)`
Handles an outlet trigger request within an HTTP handler. Reads `wfid` and `wfs` from the request, starts or resumes a workflow, and dispatches pauses to registered outlets.
```ts
handleOutlet(config: WfOutletTriggerConfig): Promise
```
| Parameter | Description |
|-----------|-------------|
| `config` | Outlet trigger configuration — see [Outlets](/wf/outlets) for full details |
```ts
@Post('flow')
async flow() {
return this.wf.handleOutlet({
allow: ['auth/login'],
state: new EncapsulatedStateStrategy({ secret: process.env.WF_SECRET! }),
outlets: [createHttpOutlet()],
})
}
```
::: info
Must be called from within an HTTP event context so that wooks HTTP composables are available. Routes through `MoostWf.start()` and `resume()` internally to preserve DI scope cleanup.
:::
### `getWfApp()`
Returns the underlying `WooksWf` instance for advanced low-level access.
```ts
getWfApp(): WooksWf
```
## Decorators (Outlets)
### `@StepTTL(ttlMs)`
Sets TTL (in ms) for the workflow state when this step pauses. The adapter wraps the step handler to set `expires` on the outlet signal.
- **`ttlMs`** *(number)* — Time-to-live in milliseconds.
```ts
@Step('send-email')
@StepTTL(30 * 60 * 1000) // 30 minutes
async sendEmail(@WorkflowParam('context') ctx: any) {
return outletEmail(ctx.email, 'verify')
}
```
## Types
### `TFlowOutput`
Returned by `start()` and `resume()`. Describes the workflow's current state.
```ts
interface TFlowOutput {
state: {
schemaId: string // workflow identifier
context: T // current context (with all mutations)
indexes: number[] // position in schema (for resume)
}
finished: boolean // true if workflow completed all steps
stepId: string // last executed step ID
inputRequired?: IR // present when a step needs input
interrupt?: boolean // true when paused (input or retriable error)
break?: boolean // true if a break condition ended the flow
resume?: (input: I) => Promise> // resume convenience function
retry?: (input?: I) => Promise> // retry convenience function
error?: Error // error if step threw (retriable or regular)
errorList?: unknown // structured error details from StepRetriableError
expires?: number // TTL in ms (if set by the step)
}
```
### `TWorkflowSchema`
Schema definition — an array of workflow items:
```ts
type TWorkflowSchema = TWorkflowItem[]
type TWorkflowItem =
| string // simple step ID
| TWorkflowStepSchemaObj // step with condition/input
| TSubWorkflowSchemaObj // nested steps (with optional while)
| TWorkflowControl // break or continue
interface TWorkflowStepSchemaObj {
id: string
condition?: string | ((ctx: T) => boolean | Promise)
input?: I
}
interface TSubWorkflowSchemaObj {
steps: TWorkflowSchema
condition?: string | ((ctx: T) => boolean | Promise)
while?: string | ((ctx: T) => boolean | Promise)
}
type TWorkflowControl =
| { continue: string | ((ctx: T) => boolean | Promise) }
| { break: string | ((ctx: T) => boolean | Promise) }
```
### `TWorkflowSpy`
Spy callback type for observing workflow execution:
```ts
type TWorkflowSpy = (
event: string,
eventOutput: string | undefined | {
fn: string | ((ctx: T) => boolean | Promise)
result: boolean
},
flowOutput: TFlowOutput,
ms?: number,
) => void
```
### `StepRetriableError`
Error class for recoverable step failures:
```ts
class StepRetriableError extends Error {
constructor(
originalError: Error,
errorList?: unknown,
inputRequired?: IR,
expires?: number,
)
readonly originalError: Error
errorList?: unknown
readonly inputRequired?: IR
expires?: number
}
```
## Outlet Types
### `WfOutletTriggerConfig`
Configuration for `handleOutlet()`:
```ts
interface WfOutletTriggerConfig {
allow?: string[]
block?: string[]
state: WfStateStrategy | ((wfid: string) => WfStateStrategy)
outlets: WfOutlet[]
token?: WfOutletTokenConfig
wfidName?: string
initialContext?: (body: Record | undefined, wfid: string) => unknown
onFinished?: (ctx: { context: unknown; schemaId: string }) => unknown
}
```
### `WfOutletTokenConfig`
Token read/write configuration:
```ts
interface WfOutletTokenConfig {
read?: Array<'body' | 'query' | 'cookie'>
write?: 'body' | 'cookie'
name?: string
consume?: boolean | Record
}
```
### `WfStateStrategy`
State persistence interface:
```ts
interface WfStateStrategy {
persist(state: WfState, options?: { ttl?: number }): Promise
retrieve(token: string): Promise
consume(token: string): Promise
}
```
### `WfOutlet`
Delivery channel interface:
```ts
interface WfOutlet {
readonly name: string
deliver(request: WfOutletRequest, token: string): Promise
}
```
### `WfFinishedResponse`
Completion response set via `useWfFinished()`:
```ts
interface WfFinishedResponse {
type: 'redirect' | 'data'
value: unknown
status?: number
cookies?: Record }>
}
```
See [Outlets](/wf/outlets) for full usage details and examples.
## Outlet Helpers
### `outletHttp(payload, context?)`
Returns an outlet signal that pauses the workflow and triggers the HTTP outlet.
```ts
return outletHttp({ type: 'form', fields: ['email'] })
```
### `outletEmail(target, template, context?)`
Returns an outlet signal that pauses the workflow and triggers the email outlet.
```ts
return outletEmail('user@example.com', 'verify', { name: 'Alice' })
```
### `outlet(name, data?)`
Generic outlet signal for custom delivery channels.
```ts
return outlet('sms', { target: '+1234567890' })
```
### `createHttpOutlet(opts?)`
Creates an HTTP delivery outlet.
```ts
const httpOutlet = createHttpOutlet({ transform: (payload) => ({ ...payload, extra: true }) })
```
### `createEmailOutlet(send)`
Creates an email delivery outlet with a user-provided send function.
```ts
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
await mailer.send({ to: target, template, data: { ...context, link: `/flow?wfs=${token}` } })
})
```
### `EncapsulatedStateStrategy`
Stateless strategy — encrypts workflow state into the token (AES-256-GCM).
```ts
new EncapsulatedStateStrategy({ secret: '...', defaultTtl?: number })
```
### `HandleStateStrategy`
Server-side strategy — stores state in a `WfStateStore`, token is an opaque handle.
```ts
new HandleStateStrategy({ store: WfStateStore, defaultTtl?: number, generateHandle?: () => string })
```
### `WfStateStoreMemory`
In-memory `WfStateStore` for development and testing.
```ts
const store = new WfStateStoreMemory()
```
## Composables
### `useWfState()`
Returns the current workflow execution state. Available inside step and entry point handlers.
```ts
import { useWfState } from '@moostjs/event-wf'
const state = useWfState()
state.ctx() // workflow context
state.input() // step input (or undefined)
state.schemaId // workflow schema ID
state.stepId() // current step ID (or null)
state.indexes // position in nested schemas
state.resume // boolean: is this a resume?
```
::: info
In most cases, use `@WorkflowParam` decorators instead of calling `useWfState()` directly. The composable is useful for advanced scenarios like custom interceptors or utilities that need workflow context.
:::
### `useWfOutlet()`
Returns outlet infrastructure accessors. Available inside step handlers during outlet-driven workflows.
```ts
import { useWfOutlet } from '@moostjs/event-wf'
const { getStateStrategy, getOutlets, getOutlet } = useWfOutlet()
```
Most steps do not need this — use `outletHttp()` / `outletEmail()` / `outlet()` instead.
### `useWfFinished()`
Sets the completion response for a finished workflow. The outlet handler uses this to determine the HTTP response when the workflow completes.
```ts
import { useWfFinished } from '@moostjs/event-wf'
useWfFinished().set({ type: 'redirect', value: '/dashboard' })
useWfFinished().get() // returns the set response, or undefined
```
See [Outlets — Workflow Completion](/wf/outlets#workflow-completion) for details.
### `wfKind`
Event kind marker for workflow events. Used internally by the adapter and useful for building custom event-type-aware logic.
```ts
import { wfKind } from '@moostjs/event-wf'
```
---
URL: "moost.org/wf/context.html"
LLMS_URL: "moost.org/wf/context.md"
---
# Context & State
The workflow context is a typed object that holds shared state across all steps. Every step in a workflow reads from and writes to the same context, making it the central place for data to flow through your pipeline.
## Defining a Context Type
Start by defining a TypeScript interface for your workflow's data:
```ts
interface TOnboardingContext {
userId: string
email: string
emailVerified: boolean
profileComplete: boolean
welcomeEmailSent: boolean
}
```
## Type-Safe Schemas
Pass your context type as a generic to `@WorkflowSchema()`. This gives you type-checked conditions:
```ts
@Workflow('onboarding')
@WorkflowSchema([ // [!code focus]
'verify-email',
{ condition: (ctx) => ctx.emailVerified, id: 'collect-profile' }, // [!code focus] ctx is typed
{ condition: (ctx) => ctx.profileComplete, id: 'send-welcome' },
])
onboarding() {}
```
The `ctx` parameter in condition functions is typed as `TOnboardingContext`, so you get autocompletion and compile-time checks.
## Providing Initial Context
When you start a workflow, pass the initial context object:
```ts
const result = await wf.start('onboarding', { // [!code focus]
userId: 'usr-42',
email: 'alice@example.com',
emailVerified: false,
profileComplete: false,
welcomeEmailSent: false,
})
```
This object is passed to the first step and persists across all subsequent steps.
## Accessing Context in Steps
### Option 1: Class Property (FOR_EVENT scope)
When the controller is event-scoped, inject context as a class property. This is the cleanest approach when multiple steps in the same controller need context access:
```ts
@Injectable('FOR_EVENT') // [!code focus]
@Controller()
export class OnboardingController {
@WorkflowParam('context') // [!code focus]
ctx!: TOnboardingContext // [!code focus]
@Step('verify-email')
verifyEmail() {
// send verification email...
this.ctx.emailVerified = true // [!code focus]
}
@Step('collect-profile')
collectProfile() {
this.ctx.profileComplete = true // same ctx, different step
}
@Step('send-welcome')
sendWelcome() {
sendEmail(this.ctx.email, 'Welcome!')
this.ctx.welcomeEmailSent = true
}
}
```
### Option 2: Method Parameter
For singleton controllers (the default scope), inject context directly into step methods:
```ts
@Controller()
export class OnboardingSteps {
@Step('verify-email')
verifyEmail(@WorkflowParam('context') ctx: TOnboardingContext) { // [!code focus]
ctx.emailVerified = true
}
@Step('collect-profile')
collectProfile(@WorkflowParam('context') ctx: TOnboardingContext) {
ctx.profileComplete = true
}
}
```
::: info
Use method parameters when the controller is a singleton — class properties would be shared across concurrent workflow executions, causing data corruption. With `@Injectable('FOR_EVENT')`, each execution gets its own controller instance, making class properties safe.
:::
## Mutating Context
Context mutations in one step are visible to all subsequent steps. The context object is passed by reference:
```ts
@Step('step-a')
stepA(@WorkflowParam('context') ctx: TMyContext) {
ctx.value = 42
}
@Step('step-b')
stepB(@WorkflowParam('context') ctx: TMyContext) {
console.log(ctx.value) // 42 — set by step-a
}
```
## Reading Context from Output
After a workflow completes (or pauses), the context is available in the output:
```ts
const result = await wf.start('onboarding', initialContext)
console.log(result.state.context) // [!code focus]
// { userId: 'usr-42', email: 'alice@example.com', emailVerified: true, ... }
```
The `state.context` reflects all mutations made by the steps that executed.
## Typing MoostWf for Output
Type the `MoostWf` instance to get typed output:
```ts
@Controller()
export class AppController {
constructor(
private wf: MoostWf, // [!code focus]
) {}
async startOnboarding(userId: string, email: string) {
const result = await this.wf.start('onboarding', {
userId,
email,
emailVerified: false,
profileComplete: false,
welcomeEmailSent: false,
})
// result.state.context is typed as TOnboardingContext
}
}
```
The generic parameter flows through to `TFlowOutput`, so `result.state.context` is properly typed.
---
URL: "moost.org/wf/errors.html"
LLMS_URL: "moost.org/wf/errors.md"
---
# Error Handling
When something goes wrong in a workflow step, you have two options: fail the workflow immediately with a regular error, or signal a recoverable failure with `StepRetriableError` that lets the workflow pause and be retried later.
## Regular Errors
Throwing a standard error fails the workflow. The output contains the error, and the workflow is marked as finished:
```ts
@Step('validate-payment')
validatePayment(@WorkflowParam('context') ctx: TPaymentContext) {
if (!ctx.paymentMethodId) {
throw new Error('No payment method configured') // [!code focus]
}
}
```
Output when a regular error is thrown:
```ts
{
finished: true, // workflow ended
error: Error('No payment method configured'),
stepId: 'validate-payment',
}
```
There is no `resume` or `retry` function — the workflow is done. Use regular errors for unrecoverable situations (missing configuration, programming errors, invalid state).
## Retriable Errors
`StepRetriableError` signals a recoverable failure. The workflow pauses instead of failing, and can be retried with corrected input:
```ts
import { Step, StepRetriableError, WorkflowParam } from '@moostjs/event-wf'
@Step('charge-card')
chargeCard(@WorkflowParam('input') input?: TPaymentInput) {
try {
processPayment(input)
} catch (e) {
throw new StepRetriableError( // [!code focus]
e as Error, // [!code focus] original error
[{ code: 'CARD_DECLINED', message: 'Card was declined' }], // [!code focus] error details
{ type: 'payment-form', hint: 'Try a different card' }, // [!code focus] input required
) // [!code focus]
}
}
```
### Constructor
```ts
new StepRetriableError(
originalError: Error, // the underlying error
errorList?: unknown, // structured error details (any shape)
inputRequired?: IR, // what input is needed to retry
expires?: number, // optional TTL in ms
)
```
All parameters except `originalError` are optional. You can throw a retriable error that just says "try again" without requesting specific input:
```ts
throw new StepRetriableError(new Error('Gateway timeout'))
```
## Retriable Error Output
When a `StepRetriableError` is thrown, the output signals a paused (not finished) workflow:
```ts
{
finished: false, // not done — can be retried // [!code focus]
interrupt: true, // execution paused // [!code focus]
error: Error('Card was declined'),
errorList: [{ code: 'CARD_DECLINED', message: 'Card was declined' }],
inputRequired: { type: 'payment-form', hint: 'Try a different card' },
stepId: 'charge-card',
retry: [Function], // retry from the same step // [!code focus]
resume: [Function], // same as retry for retriable errors
state: { schemaId: '...', context: {...}, indexes: [...] },
}
```
Key differences from a regular error:
- **`finished: false`** instead of `true`
- **`interrupt: true`** — workflow is paused, not failed
- **`retry`** function available to retry the same step
- **`state`** available for serialization and later resumption
## Retrying
Use the `retry` function to re-execute the failed step with new input:
```ts
const result = await wf.start('payment', paymentContext)
if (result.error && result.retry) {
// Show error to user, get new payment details
const newInput = { cardNumber: '4111...', cvv: '123' }
const retried = await result.retry(newInput) // [!code focus]
}
```
Or resume from stored state:
```ts
const retried = await wf.resume(result.state, newInput)
```
## When to Use Which
| Scenario | Approach |
|----------|----------|
| Invalid configuration, programming bug | `throw new Error(...)` |
| External service temporarily unavailable | `throw new StepRetriableError(...)` |
| User input fails validation | `StepRetriableError` with `errorList` and `inputRequired` |
| Data corruption, unrecoverable state | `throw new Error(...)` |
| Rate limit hit, try again later | `StepRetriableError` with `expires` |
| Payment declined, try different card | `StepRetriableError` with `inputRequired` |
::: info
A workflow can be retried multiple times. Each `retry()` or `resume()` call re-executes only the failed step and continues from there — it does not re-run previously completed steps.
:::
---
URL: "moost.org/wf/integration.html"
LLMS_URL: "moost.org/wf/integration.md"
---
# Integration
Workflow steps are regular Moost event handlers — the same interceptor, pipe, and dependency injection mechanisms that work with HTTP and CLI handlers work with workflows too. This page covers how to trigger workflows from other event types and how to use Moost's features within workflow steps.
## Triggering Workflows from HTTP
::: tip
For multi-step HTTP flows with automatic state persistence and token-based resumption (login, registration, checkout), see [Outlets](/wf/outlets) — they handle the full pause/resume/delivery cycle for you.
:::
The most common pattern is starting a workflow from an HTTP handler and returning the result to the client:
```ts
import { Controller, Param } from 'moost'
import { Get, Post, Body } from '@moostjs/event-http'
import { MoostWf } from '@moostjs/event-wf'
interface TTicketContext {
ticketId: string
description: string
status: string
assignee?: string
}
@Controller('tickets')
export class TicketController {
constructor(
private wf: MoostWf,
) {}
@Post() // [!code focus]
async createTicket(@Body() body: { description: string }) { // [!code focus]
const result = await this.wf.start('support-ticket', { // [!code focus]
ticketId: generateId(),
description: body.description,
status: 'new',
})
return {
ticketId: result.state.context.ticketId,
status: result.state.context.status,
finished: result.finished,
inputRequired: result.inputRequired,
state: result.finished ? undefined : result.state,
}
}
@Post(':id/resume') // [!code focus]
async resumeTicket(
@Param('id') id: string,
@Body() body: { state: any, input: any },
) {
const result = await this.wf.resume(body.state, body.input) // [!code focus]
return {
ticketId: id,
status: result.state.context.status,
finished: result.finished,
}
}
}
```
::: info
The workflow state (`result.state`) is plain JSON. You can return it directly to the client or store it in a database for later resumption.
:::
## Triggering Workflows from CLI
Similarly, you can start workflows from CLI commands:
```ts
import { Controller } from 'moost'
import { Cli, CliOption } from '@moostjs/event-cli'
import { MoostWf } from '@moostjs/event-wf'
@Controller()
export class DeployCommand {
constructor(private wf: MoostWf) {}
@Cli('deploy :env')
async deploy(
@Param('env') env: string,
@CliOption('dry-run', 'Simulate without applying changes')
dryRun?: boolean,
) {
const result = await this.wf.start('deploy', {
environment: env,
dryRun: !!dryRun,
steps: [],
})
return result.finished
? `Deployed to ${env} successfully`
: `Deploy paused: ${JSON.stringify(result.inputRequired)}`
}
}
```
## Using Multiple Adapters Together
Register both adapters to combine HTTP and workflow capabilities in a single application:
```ts
import { Moost } from 'moost'
import { MoostHttp } from '@moostjs/event-http'
import { MoostWf } from '@moostjs/event-wf'
const app = new Moost()
app.adapter(new MoostHttp()).listen(3000)
app.adapter(new MoostWf())
app.registerControllers(
TicketController, // HTTP handlers
TicketWfController, // Workflow steps
).init()
```
## Interceptors on Workflow Steps
Since workflow steps are event handlers, you can use `@Intercept` to add pre/post logic:
```ts
import { Intercept } from 'moost'
@Controller()
export class TicketWfController {
@Step('assign')
@Intercept(LogStepExecution) // [!code focus]
assign(@WorkflowParam('context') ctx: TTicketContext) {
ctx.assignee = findAvailableAgent()
ctx.status = 'assigned'
}
}
```
This works with all interceptor priority levels — guards, error handlers, and `AFTER_ALL` cleanup hooks.
::: info
Global interceptors registered with Moost also apply to workflow steps. This means your authentication guards, logging interceptors, and error handlers work across HTTP, CLI, and workflow events uniformly.
:::
## Pipes for Validation
Use `@Pipe` to transform or validate data flowing into workflow steps:
```ts
import { Pipe } from 'moost'
@Step('process-data')
processData(
@WorkflowParam('input')
@Pipe(validateInput) // [!code focus]
input: TProcessInput,
) {
// input is validated before reaching this point
}
```
## Dependency Injection
Workflow controllers support the same DI as the rest of Moost. Inject services via constructors:
```ts
@Injectable('FOR_EVENT')
@Controller()
export class TicketWfController {
constructor(
private emailService: EmailService, // [!code focus]
private ticketRepo: TicketRepository, // [!code focus]
) {}
@Step('notify-assignee')
async notifyAssignee(@WorkflowParam('context') ctx: TTicketContext) {
await this.emailService.send(ctx.assignee!, `Ticket ${ctx.ticketId} assigned to you`)
}
@Step('save-ticket')
async saveTicket(@WorkflowParam('context') ctx: TTicketContext) {
await this.ticketRepo.update(ctx.ticketId, { status: ctx.status })
}
}
```
## Accessing the Underlying WooksWf Instance
For advanced scenarios, access the raw `WooksWf` instance:
```ts
const wf = new MoostWf()
const wooksWf = wf.getWfApp() // [!code focus]
```
This gives you direct access to the low-level workflow engine from `@wooksjs/event-wf`.
---
URL: "moost.org/wf/outlets.html"
LLMS_URL: "moost.org/wf/outlets.md"
---
# Outlets
Outlets extend the pause/resume model with **state persistence** and **delivery channels**. Instead of manually serializing workflow state and building resume endpoints, outlets handle the full cycle: persist paused state into a token, deliver that token to the user (via HTTP response or email), and resume the workflow when the token comes back.
This is ideal for multi-step HTTP flows — login, registration, password recovery, checkout — where each step collects user input through a form, email link, or API call.
## How Outlets Work
1. A step pauses by returning an **outlet signal** (`outletHttp(...)` or `outletEmail(...)`)
2. The outlet handler **persists** the workflow state into a token (encrypted or server-stored)
3. The appropriate **outlet delivers** the token — HTTP outlet returns it in the response body, email outlet sends it in a link
4. The user **resumes** by sending the token back (in a form submission, query param, or cookie)
5. The outlet handler **retrieves** the state from the token and resumes the workflow
All of this is orchestrated by a single method: `moostWf.handleOutlet(config)`.
## Setup
Install or update the required packages:
```bash
pnpm add @moostjs/event-wf@latest
```
The outlet APIs come from the underlying packages (`@prostojs/wf` v0.1.1+ and `@wooksjs/event-wf` v0.7.8+), but everything is re-exported from `@moostjs/event-wf` for convenience — you only need one import source.
## Quick Example
A login flow with an HTTP outlet that serves forms and collects user input:
```ts
import { Controller, Post, Injectable } from 'moost'
import {
MoostWf,
Workflow, WorkflowSchema, Step, WorkflowParam,
outletHttp, useWfFinished,
createHttpOutlet, EncapsulatedStateStrategy,
} from '@moostjs/event-wf'
const state = new EncapsulatedStateStrategy({ // [!code focus]
secret: process.env.WF_SECRET!, // 32-byte hex string // [!code focus]
}) // [!code focus]
const httpOutlet = createHttpOutlet() // [!code focus]
@Injectable()
@Controller('auth')
class AuthController {
constructor(private wf: MoostWf) {}
@Post('flow')
async flow() {
return this.wf.handleOutlet({ // [!code focus]
allow: ['auth/login'], // [!code focus]
state, // [!code focus]
outlets: [httpOutlet], // [!code focus]
}) // [!code focus]
}
@Workflow('auth/login')
@WorkflowSchema(['credentials', 'create-session'])
loginFlow() {}
@Step('credentials')
async credentials(
@WorkflowParam('input') input?: { username: string; password: string },
@WorkflowParam('context') ctx: any,
) {
if (input) {
ctx.userId = await verifyCredentials(input.username, input.password)
return
}
return outletHttp({ type: 'login-form', fields: ['username', 'password'] }) // [!code focus]
}
@Step('create-session')
async createSession(@WorkflowParam('context') ctx: any) {
const session = await createUserSession(ctx.userId)
useWfFinished().set({ // [!code focus]
type: 'redirect',
value: '/dashboard',
cookies: { sid: { value: session.id, options: { httpOnly: true } } },
})
}
}
```
**What happens at runtime:**
1. Client sends `POST /auth/flow` with `{ "wfid": "auth/login" }`
2. Workflow starts → `credentials` step returns `outletHttp(...)` → workflow pauses
3. State is encrypted into a token → HTTP outlet returns the form payload + token (`wfs`) in the response
4. Client renders the form, user fills it in, sends `POST /auth/flow` with `{ "wfs": "", "username": "...", "password": "..." }`
5. State is decrypted from token → workflow resumes at `credentials` with input → `create-session` runs → workflow finishes
6. Response includes the redirect and session cookie from `useWfFinished()`
## The `handleOutlet` Method
`MoostWf.handleOutlet(config)` is the single entry point for outlet-driven workflows. Call it from any HTTP handler — it reads `wfid` and `wfs` from the request, starts or resumes the workflow, and returns the result.
```ts
@Post('flow')
async flow() {
return this.wf.handleOutlet({
allow: ['auth/login', 'auth/recovery'], // which workflows are allowed
state: stateStrategy, // how to persist/retrieve paused state
outlets: [httpOutlet, emailOutlet], // delivery channels
})
}
```
It routes through `MoostWf.start()` and `resume()` internally, so all Moost DI, interceptors, and pipes work normally in your workflow steps.
### Configuration
The full `WfOutletTriggerConfig` interface:
| Field | Type | Description |
|-------|------|-------------|
| `allow` | `string[]` | Whitelist of workflow IDs that can be started. Empty = all allowed |
| `block` | `string[]` | Blacklist of workflow IDs (checked after `allow`) |
| `state` | `WfStateStrategy \| (wfid: string) => WfStateStrategy` | State persistence strategy (required) |
| `outlets` | `WfOutlet[]` | Registered delivery channels (required) |
| `token` | `WfOutletTokenConfig` | Token read/write/naming configuration |
| `wfidName` | `string` | Request parameter name for workflow ID (default: `'wfid'`) |
| `initialContext` | `(body, wfid) => unknown` | Factory for initial workflow context |
| `onFinished` | `(ctx) => unknown` | Override the completion response |
### Token Configuration
Control where the state token is read from and written to:
```ts
this.wf.handleOutlet({
// ...
token: {
read: ['body', 'query', 'cookie'], // where to look for the token (default: all three)
write: 'body', // where to write the token in the response (default: 'body')
name: 'wfs', // parameter name (default: 'wfs')
consume: { email: true }, // consume (invalidate) tokens per outlet
},
})
```
When `consume` is enabled for an outlet, the token is deleted from the store after retrieval — useful for single-use email magic links. With `EncapsulatedStateStrategy` (stateless tokens), consumption has no effect since there is no server-side state to delete.
## State Strategies
A state strategy controls how paused workflow state is persisted and retrieved. Two strategies are provided.
### EncapsulatedStateStrategy
Encrypts the entire workflow state into the token itself using AES-256-GCM. No server-side storage needed — the token is self-contained.
```ts
import { EncapsulatedStateStrategy } from '@moostjs/event-wf'
const state = new EncapsulatedStateStrategy({
secret: process.env.WF_SECRET!, // must be exactly 32 bytes (64-char hex string)
defaultTtl: 30 * 60 * 1000, // optional: default expiration (30 minutes)
})
```
**Pros:** Zero infrastructure, horizontally scalable, no cleanup needed.
**Cons:** Tokens are larger (they contain the full state), cannot be revoked server-side.
### HandleStateStrategy
Stores state server-side with an opaque handle as the token. Requires a `WfStateStore` implementation.
```ts
import { HandleStateStrategy, WfStateStoreMemory } from '@moostjs/event-wf'
// In-memory store (for development/testing)
const store = new WfStateStoreMemory()
const state = new HandleStateStrategy({
store,
defaultTtl: 30 * 60 * 1000,
generateHandle: () => crypto.randomUUID(), // optional custom handle generator
})
```
**Pros:** Small tokens, server-side revocation, true token consumption.
**Cons:** Requires a persistent store in production.
#### Custom Store
Implement `WfStateStore` to use your own database:
```ts
import type { WfStateStore, WfState } from '@moostjs/event-wf'
class RedisStateStore implements WfStateStore {
constructor(private redis: RedisClient) {}
async set(handle: string, state: WfState, expiresAt?: number) {
const ttl = expiresAt ? Math.ceil((expiresAt - Date.now()) / 1000) : 0
await this.redis.set(`wf:${handle}`, JSON.stringify(state), ttl ? { EX: ttl } : {})
}
async get(handle: string) {
const data = await this.redis.get(`wf:${handle}`)
return data ? { state: JSON.parse(data) } : null
}
async delete(handle: string) {
await this.redis.del(`wf:${handle}`)
}
async getAndDelete(handle: string) {
const data = await this.get(handle)
if (data) await this.delete(handle)
return data
}
}
```
### Per-Workflow Strategy
Pass a function to use different strategies per workflow:
```ts
this.wf.handleOutlet({
state: (wfid) => {
if (wfid.startsWith('auth/')) return authStrategy
return defaultStrategy
},
// ...
})
```
## Outlets
An outlet is a delivery channel that sends the state token to the user. Two outlets are provided.
### HTTP Outlet
Returns the outlet payload as the HTTP response body. The client receives the form definition and the state token, renders the form, and submits back to the same endpoint.
```ts
import { createHttpOutlet } from '@moostjs/event-wf'
const httpOutlet = createHttpOutlet()
// With a transform function to reshape the payload:
const httpOutlet = createHttpOutlet({
transform: (payload, context) => ({
...payload,
csrfToken: generateCsrf(),
}),
})
```
In your step, return `outletHttp(payload)` to trigger the HTTP outlet:
```ts
@Step('collect-address')
async collectAddress(
@WorkflowParam('input') input?: TAddress,
@WorkflowParam('context') ctx: TCheckoutContext,
) {
if (input) {
ctx.address = input
return
}
return outletHttp({
type: 'address-form',
fields: ['street', 'city', 'zip', 'country'],
defaults: ctx.address,
})
}
```
The response the client receives looks like:
```json
{
"wfs": "",
"inputRequired": {
"outlet": "http",
"payload": {
"type": "address-form",
"fields": ["street", "city", "zip", "country"],
"defaults": null
}
}
}
```
### Email Outlet
Sends a token via email. You provide the send function — the outlet handles token generation and delivery orchestration.
```ts
import { createEmailOutlet } from '@moostjs/event-wf'
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
await mailer.send({
to: target,
subject: getSubject(template),
html: renderTemplate(template, {
...context,
actionUrl: `https://app.example.com/auth/flow?wfs=${token}`,
}),
})
})
```
In your step, return `outletEmail(target, template, context?)` to send an email:
```ts
@Step('send-verification')
@StepTTL(30 * 60 * 1000) // link valid for 30 minutes
async sendVerification(@WorkflowParam('context') ctx: TRegistrationContext) {
return outletEmail(ctx.email, 'verify-email', { name: ctx.name })
}
```
When the user clicks the link, the `wfs` token in the query string resumes the workflow.
### Custom Outlets
Implement the `WfOutlet` interface for other delivery channels (SMS, push notifications, webhooks):
```ts
import type { WfOutlet, WfOutletRequest, WfOutletResult } from '@moostjs/event-wf'
const smsOutlet: WfOutlet = {
name: 'sms',
async deliver(request: WfOutletRequest, token: string): Promise {
await smsService.send({
to: request.target!,
body: `Your code: ${token.slice(0, 6)}`, // short code from token
})
// Return void — no HTTP response needed for SMS
},
}
```
Use the generic `outlet()` helper in your step:
```ts
import { outlet } from '@moostjs/event-wf'
@Step('send-sms-code')
async sendSmsCode(@WorkflowParam('context') ctx: any) {
return outlet('sms', { target: ctx.phone })
}
```
## Step TTL
The `@StepTTL(ms)` decorator sets an expiration time on the paused state token. The state strategy uses this to auto-expire persisted state.
```ts
import { StepTTL } from '@moostjs/event-wf'
@Step('send-recovery-email')
@StepTTL(30 * 60 * 1000) // 30 minutes // [!code focus]
async sendRecoveryEmail(@WorkflowParam('context') ctx: any) {
return outletEmail(ctx.email, 'recovery', { name: ctx.name })
}
```
Without `@StepTTL`, the strategy's `defaultTtl` is used (or no expiration if that is also unset).
You can also set expiration inline without the decorator:
```ts
return { ...outletEmail(ctx.email, 'recovery'), expires: Date.now() + 30 * 60 * 1000 }
```
## Workflow Completion
Use the `useWfFinished()` composable in the final step to control what the HTTP trigger returns when the workflow completes:
```ts
import { useWfFinished } from '@moostjs/event-wf'
@Step('complete')
async complete(@WorkflowParam('context') ctx: any) {
useWfFinished().set({
type: 'redirect', // 'redirect' or 'data'
value: '/dashboard', // URL for redirect, or response body for data
status: 302, // optional HTTP status
cookies: { // optional cookies to set
'session-id': {
value: ctx.sessionId,
options: { httpOnly: true, secure: true },
},
},
})
}
```
If no `useWfFinished()` is called, the outlet handler returns the raw workflow output.
## Complete Example: Auth Workflows
A real-world controller with login and password recovery flows sharing a single HTTP endpoint:
```ts
import { Controller, Post, Injectable } from 'moost'
import {
MoostWf,
Workflow, WorkflowSchema, Step, WorkflowParam, StepTTL,
outletHttp, outletEmail, useWfFinished,
createHttpOutlet, createEmailOutlet,
EncapsulatedStateStrategy,
} from '@moostjs/event-wf'
const stateStrategy = new EncapsulatedStateStrategy({
secret: process.env.WF_SECRET!,
})
const httpOutlet = createHttpOutlet()
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
await mailer.send({
to: target,
template,
data: { ...context, verifyUrl: `/auth/flow?wfs=${token}` },
})
})
@Injectable()
@Controller('auth')
class AuthController {
constructor(
private wf: MoostWf,
private users: UserService,
private sessions: SessionService,
) {}
// --- Single HTTP endpoint for all auth workflows ---
@Post('flow')
async flow() {
return this.wf.handleOutlet({
allow: ['auth/login', 'auth/recovery'],
state: stateStrategy,
outlets: [httpOutlet, emailOutlet],
token: { consume: { email: true } },
})
}
// --- Login workflow ---
@Workflow('auth/login')
@WorkflowSchema([
'login-form',
{ condition: (ctx) => ctx.mfaRequired, steps: ['mfa-verify'] },
'create-session',
])
loginFlow() {}
@Step('login-form')
async loginForm(
@WorkflowParam('input') input?: { username: string; password: string },
@WorkflowParam('context') ctx: any,
) {
if (input) {
const user = await this.users.authenticate(input.username, input.password)
ctx.userId = user.id
ctx.mfaRequired = user.mfaEnabled
return
}
return outletHttp({ type: 'login', fields: ['username', 'password'] })
}
@Step('mfa-verify')
async mfaVerify(
@WorkflowParam('input') input?: { code: string },
@WorkflowParam('context') ctx: any,
) {
if (input) {
await this.users.verifyMfa(ctx.userId, input.code)
return
}
return outletHttp({ type: 'mfa', fields: ['code'] })
}
// --- Recovery workflow ---
@Workflow('auth/recovery')
@WorkflowSchema(['recovery-email', 'send-link', 'reset-password', 'create-session'])
recoveryFlow() {}
@Step('recovery-email')
async recoveryEmail(
@WorkflowParam('input') input?: { email: string },
@WorkflowParam('context') ctx: any,
) {
if (input) {
const user = await this.users.findByEmail(input.email)
ctx.userId = user.id
ctx.email = user.email
ctx.name = user.name
return
}
return outletHttp({ type: 'email-form', fields: ['email'] })
}
@Step('send-link')
@StepTTL(30 * 60 * 1000) // 30-minute magic link
async sendLink(@WorkflowParam('context') ctx: any) {
return outletEmail(ctx.email, 'recovery', { name: ctx.name })
}
@Step('reset-password')
async resetPassword(
@WorkflowParam('input') input?: { password: string },
@WorkflowParam('context') ctx: any,
) {
if (input) {
await this.users.resetPassword(ctx.userId, input.password)
return
}
return outletHttp({ type: 'password-form', fields: ['password'] })
}
// --- Shared completion step ---
@Step('create-session')
async createSession(@WorkflowParam('context') ctx: any) {
const session = await this.sessions.create(ctx.userId)
useWfFinished().set({
type: 'redirect',
value: '/dashboard',
cookies: { sid: { value: session.id, options: { httpOnly: true } } },
})
}
}
```
**Client-side flow for login:**
```
POST /auth/flow { "wfid": "auth/login" }
← 200 { "wfs": "...", "inputRequired": { "outlet": "http", "payload": { "type": "login", ... } } }
POST /auth/flow { "wfs": "...", "username": "alice", "password": "s3cret" }
← 200 { "wfs": "...", "inputRequired": { "outlet": "http", "payload": { "type": "mfa", ... } } }
(or skip MFA if not required → redirect directly)
POST /auth/flow { "wfs": "...", "code": "123456" }
← 302 Location: /dashboard Set-Cookie: sid=...
```
**Client-side flow for recovery:**
```
POST /auth/flow { "wfid": "auth/recovery" }
← 200 { "wfs": "...", "inputRequired": { "outlet": "http", "payload": { "type": "email-form", ... } } }
POST /auth/flow { "wfs": "...", "email": "alice@example.com" }
← 200 (email sent, workflow paused at email outlet — no wfs in response)
GET /auth/flow?wfs=
← 200 { "wfs": "...", "inputRequired": { "outlet": "http", "payload": { "type": "password-form", ... } } }
POST /auth/flow { "wfs": "...", "password": "n3wP@ss" }
← 302 Location: /dashboard Set-Cookie: sid=...
```
## Initial Context
Use `initialContext` to seed the workflow context from the request body when starting:
```ts
this.wf.handleOutlet({
// ...
initialContext: (body, wfid) => ({
startedAt: Date.now(),
source: body?.referrer ?? 'direct',
}),
})
```
The function receives the parsed request body and the workflow ID.
## Composables
### `useWfOutlet()`
Advanced composable for accessing outlet infrastructure from within a workflow step:
```ts
import { useWfOutlet } from '@moostjs/event-wf'
const { getStateStrategy, getOutlets, getOutlet } = useWfOutlet()
```
Most steps do not need this — use `outletHttp()` / `outletEmail()` / `outlet()` instead.
### `useWfFinished()`
Sets the completion response when the workflow finishes. See [Workflow Completion](#workflow-completion) above.
```ts
import { useWfFinished } from '@moostjs/event-wf'
useWfFinished().set({ type: 'data', value: { success: true } })
```
---
URL: "moost.org/wf/overview.html"
LLMS_URL: "moost.org/wf/overview.md"
---
# Workflows Overview
Moost Workflows let you model multi-step processes as composable, typed sequences of operations. Instead of tangling business logic into deeply nested functions, you define discrete **steps**, arrange them into a **schema**, and let the workflow engine handle execution order, branching, pausing, and resumption.
## When to Use Workflows
Workflows are a good fit when your process:
- **Has multiple stages** that must execute in a specific order
- **Needs branching** based on runtime data (approve/reject, retry/abort)
- **Can be interrupted** to wait for user input, external approval, or async events
- **Requires auditability** — you need to trace what ran, when, and with what data
- **Spans time** — the process may take minutes, hours, or days to complete
Common examples: user onboarding, order fulfillment, document approval, data import pipelines, multi-step forms.
## Core Concepts
| Concept | What it does |
|---------|-------------|
| [**Steps**](/wf/steps) | Individual units of work — controller methods decorated with `@Step` |
| [**Schemas**](/wf/schemas) | Declarative arrays defining execution order, conditions, and loops |
| [**Context**](/wf/context) | Typed shared state that persists across all steps in a workflow |
| [**Pause & Resume**](/wf/pause-resume) | Interrupt a workflow to collect input, then continue from where it stopped |
| [**Error Handling**](/wf/errors) | Retriable errors that pause instead of fail, enabling graceful recovery |
| [**Spies**](/wf/spies) | Observers that monitor step execution for logging, timing, or auditing |
## How It Works
A typical workflow lifecycle:
1. **Define steps** as controller methods, each handling one piece of logic
2. **Compose a schema** that arranges steps with conditions and loops
3. **Start the workflow** with an initial context object
4. **Steps execute** in sequence, reading and mutating the shared context
5. **Workflow completes** — or **pauses** if a step needs input, then resumes later
```
start(schemaId, context)
|
v
[ step 1 ] --> [ step 2 ] --> { condition? } --yes--> [ step 3a ]
|
no
|
v
[ step 3b ] --> done
```
The workflow output tells you whether it finished, paused for input, or encountered an error — along with the full serializable state you can store and resume from later.
## What's Next?
Ready to try it? Head to the [Getting Started](/wf/) guide to build your first workflow.
---
URL: "moost.org/wf/pause-resume.html"
LLMS_URL: "moost.org/wf/pause-resume.md"
---
# Pause & Resume
Workflows can pause mid-execution to wait for external input — user data, approvals, API callbacks — and then resume exactly where they left off. The workflow state is fully serializable, so you can store it in a database and resume hours or days later.
## Pausing a Workflow
A step pauses the workflow by returning an object with `inputRequired`:
```ts
@Step('collect-address')
collectAddress(
@WorkflowParam('input') input?: TAddress,
@WorkflowParam('context') ctx: TRegistrationContext,
) {
if (!input) {
return { inputRequired: true } // [!code focus] pauses the workflow
}
ctx.address = input
}
```
The `inputRequired` value can be anything your application needs — a boolean, a form schema, a list of required fields, or a structured object. Moost passes it through to the output without interpretation.
```ts
// Flexible inputRequired examples:
return { inputRequired: true }
return { inputRequired: { fields: ['name', 'email'] } }
return { inputRequired: { formId: 'address-form', version: 2 } }
```
## Reading the Paused Output
When a step requests input, the output signals that the workflow is paused:
```ts
const result = await wf.start('registration', {
userId: 'usr-1',
name: '',
email: '',
address: null,
})
if (!result.finished) { // [!code focus]
console.log(result.interrupt) // true
console.log(result.inputRequired) // true (or whatever you returned)
console.log(result.stepId) // 'collect-address'
console.log(result.state) // serializable state
}
```
Key output fields when paused:
- **`finished: false`** — workflow did not complete
- **`interrupt: true`** — execution was paused (not failed)
- **`inputRequired`** — the value returned by the step
- **`state`** — full state needed to resume (schema ID, context, step position)
- **`resume`** — convenience function to resume immediately
## Resuming with the Convenience Function
The output includes a `resume` function you can call directly:
```ts
const result = await wf.start('registration', initialContext)
if (result.inputRequired && result.resume) {
const resumed = await result.resume({ // [!code focus]
street: '123 Main St',
city: 'Springfield',
zip: '62701',
})
console.log(resumed.finished) // true (if no more input needed)
}
```
This is handy for in-process resumption where you don't need to serialize the state.
## Resuming from Stored State
For workflows that span time (user goes away, comes back later), serialize the state and resume with `wf.resume()`:
```ts
// Step 1: Start workflow, get paused state
const result = await wf.start('registration', initialContext)
if (result.inputRequired) {
// Step 2: Store the state (it's plain JSON) // [!code focus]
await db.save('pending-workflows', {
id: result.state.schemaId,
state: result.state, // { schemaId, context, indexes } // [!code focus]
inputRequired: result.inputRequired,
})
}
// ... later, when the user provides input ...
// Step 3: Load and resume // [!code focus]
const saved = await db.load('pending-workflows', workflowId)
const resumed = await wf.resume(saved.state, userInput) // [!code focus]
```
The `state` object contains everything needed to resume:
- **`schemaId`** — which workflow to resume
- **`context`** — the workflow context as it was when paused
- **`indexes`** — position in the schema (including nested sub-workflows)
## Multi-Step Pause/Resume
A workflow can pause and resume multiple times. Each resume continues from the paused step and runs until the next pause or completion:
```ts
@WorkflowSchema([
'collect-name', // may pause for input
'collect-email', // may pause for input
'collect-address', // may pause for input
'create-account',
'send-welcome',
])
```
```ts
// First run — pauses at 'collect-name'
let result = await wf.start('registration', emptyContext)
// Resume with name — runs 'collect-name', then pauses at 'collect-email'
result = await wf.resume(result.state, { name: 'Alice' })
// Resume with email — runs 'collect-email', then pauses at 'collect-address'
result = await wf.resume(result.state, { email: 'alice@example.com' })
// Resume with address — runs remaining steps
result = await wf.resume(result.state, { street: '123 Main', city: 'Springfield' })
console.log(result.finished) // true
```
## Expiration
Steps can set an expiration time (in milliseconds) for paused state:
```ts
@Step('collect-payment')
collectPayment(@WorkflowParam('input') input?: TPayment) {
if (!input) {
return {
inputRequired: { type: 'payment-form' },
expires: 15 * 60 * 1000, // [!code focus] 15 minutes
}
}
// process payment...
}
```
The `expires` value appears in the output. It's up to your application to check it and reject stale resumes:
```ts
if (result.expires && Date.now() > result.expires) {
throw new Error('Workflow session expired')
}
```
::: info
The workflow engine does not enforce expiration automatically — it provides the value for your application to use. This gives you control over how to handle expired states (reject, extend, restart, etc.).
:::
## Next: Outlets
For workflows that need **automated state persistence**, **token-based resumption**, and **delivery via HTTP responses or email**, see [Outlets](/wf/outlets). Outlets build on top of pause/resume to handle the full cycle — encrypting state into tokens, delivering them, and resuming when the token comes back — without manual serialization.
---
URL: "moost.org/wf/schemas.html"
LLMS_URL: "moost.org/wf/schemas.md"
---
# Schemas & Flow Control
A workflow schema defines the execution order of steps. It's an array attached to a workflow entry point via `@WorkflowSchema`, describing which steps run, in what order, and under what conditions.
## Linear Schemas
The simplest schema is a flat list of step IDs. Steps run one after another:
```ts
@Workflow('deploy')
@WorkflowSchema(['build', 'test', 'publish'])
deploy() {}
```
## Conditional Steps
Add a `condition` to run a step only when the condition evaluates to `true`. Conditions receive the workflow context:
```ts
interface TCampaignContext {
audienceSize: number
approved: boolean
sent: boolean
}
@Workflow('email-campaign')
@WorkflowSchema([
'prepare-audience',
{ condition: (ctx) => ctx.audienceSize > 0, id: 'request-approval' }, // [!code focus]
{ condition: (ctx) => ctx.approved, id: 'send-emails' }, // [!code focus]
'generate-report',
])
emailCampaign() {}
```
If a condition returns `false`, that step is skipped and execution continues with the next item in the schema.
## String Conditions
Conditions can also be strings. String conditions are evaluated against the workflow context using a `with(ctx)` scope, making them easy to serialize and store in a database:
```ts
@WorkflowSchema([
'prepare-audience',
{ condition: 'audienceSize > 0', id: 'request-approval' }, // [!code focus]
{ condition: 'approved', id: 'send-emails' }, // [!code focus]
'generate-report',
])
```
::: info
String conditions are evaluated using `new Function()` with the context properties available as globals. This means you can reference any context field directly: `'amount > 100'`, `'status === "active"'`, etc.
:::
## Loops
Use `while` with a nested `steps` array to repeat a group of steps until the condition becomes `false`:
```ts
@WorkflowSchema([
{
while: (ctx) => !ctx.sent && ctx.retries < 3, // [!code focus]
steps: [ // [!code focus]
'attempt-send', // [!code focus]
'increment-retries', // [!code focus]
], // [!code focus]
},
{ condition: (ctx) => !ctx.sent, id: 'log-failure' },
])
```
The `while` condition (function or string) is checked before each iteration. When it returns `false`, execution moves past the loop.
## Break and Continue
Inside a loop's `steps` array, you can use `break` and `continue` for flow control:
```ts
@WorkflowSchema([
{
while: 'running',
steps: [
'check-temperature',
{ break: 'temperature > safeLimit' }, // [!code focus] exits the loop
'process-batch',
{ continue: 'skipCooling' }, // [!code focus] skips to next iteration
'cool-down',
],
},
'shutdown',
])
```
- **`break`** — exits the loop immediately when the condition is `true`
- **`continue`** — skips the remaining steps in the current iteration and starts the next one
Both accept function or string conditions, just like `condition` and `while`.
## Nested Sub-Workflows
You can nest a `steps` array without a `while` condition to group steps logically. This is useful for applying a shared condition to a block:
```ts
@WorkflowSchema([
'prepare-audience',
{
condition: (ctx) => ctx.audienceSize > 1000, // [!code focus]
steps: [ // [!code focus] only run this block for large audiences
'segment-audience',
'schedule-batches',
'warm-up-ips',
],
},
'send-emails',
])
```
## Input in Schema
You can pass input to a specific step directly from the schema:
```ts
@WorkflowSchema([
{ id: 'notify', input: { channel: 'email' } }, // [!code focus]
{ id: 'notify', input: { channel: 'sms' } },
])
```
The step receives this input via `@WorkflowParam('input')`.
## Complete Example
Here's a schema combining multiple features — an email campaign with retry logic:
```ts
interface TCampaignContext {
audienceSize: number
approved: boolean
sent: boolean
attempts: number
maxAttempts: number
}
@Controller()
export class CampaignController {
@WorkflowParam('context')
ctx!: TCampaignContext
@Workflow('email-campaign')
@WorkflowSchema([
'prepare-audience',
{ condition: (ctx) => ctx.audienceSize === 0, id: 'no-audience' },
'request-approval',
{ condition: (ctx) => !ctx.approved, id: 'cancelled' },
{
while: (ctx) => !ctx.sent && ctx.attempts < ctx.maxAttempts,
steps: [
'attempt-send',
{ break: 'sent' },
'wait-before-retry',
],
},
{ condition: (ctx) => ctx.sent, id: 'send-report' },
{ condition: (ctx) => !ctx.sent, id: 'escalate-failure' },
])
emailCampaign() {}
@Step('prepare-audience')
prepareAudience() { /* ... */ }
@Step('no-audience')
noAudience() { this.ctx.sent = false }
@Step('request-approval')
requestApproval() {
return { inputRequired: { question: 'Approve this campaign?' } }
}
@Step('cancelled')
cancelled() { /* log cancellation */ }
@Step('attempt-send')
attemptSend() {
this.ctx.attempts++
// try sending...
this.ctx.sent = true
}
@Step('wait-before-retry')
waitBeforeRetry() { /* delay logic */ }
@Step('send-report')
sendReport() { /* generate report */ }
@Step('escalate-failure')
escalateFailure() { /* alert ops team */ }
}
```
## Schema Type Reference
A schema is an array of items. Each item can be:
| Form | Description |
|------|-------------|
| `'stepId'` | Run the step unconditionally |
| `{ id: 'stepId' }` | Run the step (same as string form) |
| `{ id: 'stepId', condition: fn \| string }` | Run only if condition passes |
| `{ id: 'stepId', input: value }` | Run with specific input |
| `{ steps: [...] }` | Nested group of steps |
| `{ steps: [...], condition: fn \| string }` | Conditional group |
| `{ steps: [...], while: fn \| string }` | Loop until condition is false |
| `{ break: fn \| string }` | Exit enclosing loop if condition passes |
| `{ continue: fn \| string }` | Skip to next loop iteration if condition passes |
---
URL: "moost.org/wf/spies.html"
LLMS_URL: "moost.org/wf/spies.md"
---
# Spies & Observability
Spies are observer functions that monitor workflow execution in real time. Attach a spy to receive callbacks as steps execute, conditions evaluate, and workflows start or finish. This is useful for logging, timing, metrics, and audit trails.
## Attaching a Spy
Call `attachSpy` on the `MoostWf` instance. It returns a detach function:
```ts
import { MoostWf } from '@moostjs/event-wf'
const wf = new MoostWf()
const detach = wf.attachSpy((event, eventOutput, flowOutput, ms) => { // [!code focus]
console.log(`[${event}] step=${flowOutput.stepId} (${ms}ms)`)
})
// ... run workflows ...
detach() // stop observing // [!code focus]
```
## Spy Callback Signature
The spy function receives four arguments:
```ts
type TWorkflowSpy = (
event: string,
eventOutput: string | undefined | { fn: string | Function, result: boolean },
flowOutput: TFlowOutput,
ms?: number,
) => void
```
| Argument | Description |
|----------|-------------|
| `event` | Event name (e.g., `'step'`, `'workflow-start'`, `'eval-condition-fn'`) |
| `eventOutput` | Event-specific data — step ID for steps, condition result for conditions |
| `flowOutput` | Current workflow output snapshot (context, finished, stepId, etc.) |
| `ms` | Elapsed time in milliseconds (from workflow start) |
## Spy Events
| Event | When it fires | `eventOutput` |
|-------|--------------|---------------|
| `'workflow-start'` | Workflow begins | `undefined` |
| `'subflow-start'` | Nested sub-workflow begins | `undefined` |
| `'step'` | A step finishes executing | Step ID (string) |
| `'eval-condition-fn'` | A step condition is evaluated | `{ fn, result: boolean }` |
| `'eval-while-cond'` | A `while` loop condition is evaluated | `{ fn, result: boolean }` |
| `'eval-break-fn'` | A `break` condition is evaluated | `{ fn, result: boolean }` |
| `'eval-continue-fn'` | A `continue` condition is evaluated | `{ fn, result: boolean }` |
| `'workflow-end'` | Workflow completes normally | `undefined` |
| `'workflow-interrupt'` | Workflow pauses (input required or retriable error) | `undefined` |
| `'subflow-end'` | Nested sub-workflow completes | `undefined` |
## Practical Examples
### Step Timing Logger
```ts
wf.attachSpy((event, eventOutput, flowOutput, ms) => {
if (event === 'step') {
console.log(`Step "${flowOutput.stepId}" completed in ${ms}ms`)
}
})
```
### Audit Trail
```ts
const auditLog: Array<{ event: string, stepId: string, timestamp: number }> = []
wf.attachSpy((event, _output, flowOutput) => {
auditLog.push({
event,
stepId: flowOutput.stepId,
timestamp: Date.now(),
})
})
// After workflow completes:
await saveAuditLog(auditLog)
```
### Condition Debugging
```ts
wf.attachSpy((event, eventOutput) => {
if (typeof eventOutput === 'object' && eventOutput && 'result' in eventOutput) {
const condStr = typeof eventOutput.fn === 'string'
? eventOutput.fn
: eventOutput.fn.toString()
console.log(`${event}: ${condStr} => ${eventOutput.result}`)
}
})
```
## Detaching Spies
You can detach a spy in two ways:
```ts
// Option 1: Use the returned detach function
const detach = wf.attachSpy(mySpy)
detach()
// Option 2: Pass the same function to detachSpy
wf.attachSpy(mySpy)
wf.detachSpy(mySpy)
```
::: info
Spies are called synchronously during workflow execution. Keep spy callbacks lightweight to avoid slowing down your workflows. For heavy processing (database writes, API calls), buffer events and process them asynchronously.
:::
---
URL: "moost.org/wf/steps.html"
LLMS_URL: "moost.org/wf/steps.md"
---
# Steps
Steps are the building blocks of a workflow. Each step is a controller method decorated with `@Step` that handles one unit of work. Steps read and write to the shared context, can receive input, and can signal that the workflow should pause.
## Defining a Step
Decorate a controller method with `@Step('id')`. The string is the step ID used in workflow schemas:
```ts
import { Step, WorkflowParam } from '@moostjs/event-wf'
import { Controller } from 'moost'
@Controller()
export class ApprovalController {
@Step('review') // [!code focus]
review(@WorkflowParam('context') ctx: TApprovalContext) {
ctx.reviewedAt = new Date().toISOString()
ctx.status = 'reviewed'
}
}
```
If you omit the path, the method name is used as the step ID.
## Accessing Workflow Data
Use `@WorkflowParam` to inject workflow data into step methods:
| Parameter | Type | Description |
|-----------|------|-------------|
| `'context'` | `T` | Shared workflow context — read and mutate it freely |
| `'input'` | `I \| undefined` | Input passed to `start()` or `resume()` |
| `'stepId'` | `string \| null` | Current step identifier |
| `'schemaId'` | `string` | Workflow schema identifier |
| `'indexes'` | `number[]` | Position in nested schema (for sub-workflows) |
| `'resume'` | `boolean` | `true` when the workflow is being resumed, `false` on first run |
| `'state'` | `object` | Full workflow state (context, indexes, schemaId) |
```ts
@Step('process')
process(
@WorkflowParam('context') ctx: TMyContext,
@WorkflowParam('input') input: TMyInput | undefined,
@WorkflowParam('resume') isResume: boolean,
) {
if (isResume) {
console.log('Resuming with new input:', input)
}
ctx.processed = true
}
```
## Class Property Injection
When using `@Injectable('FOR_EVENT')`, the controller is instantiated fresh for each step execution. This lets you inject context as a class property instead of a method parameter:
```ts
@Injectable('FOR_EVENT') // [!code focus]
@Controller()
export class ApprovalController {
@WorkflowParam('context') // [!code focus]
ctx!: TApprovalContext // [!code focus]
@Step('review')
review() {
this.ctx.status = 'reviewed' // access via this.ctx
}
@Step('approve')
approve() {
this.ctx.status = 'approved' // same property, different step
}
}
```
::: info
Without `@Injectable('FOR_EVENT')`, the controller is a singleton shared across events. In that case, use method parameters instead of class properties to avoid state leaking between concurrent workflows.
:::
## Parametric Steps
Steps can have parametric paths, just like HTTP routes. Use `@Param` from `moost` to extract values:
```ts
import { Param } from 'moost'
@Step('notify/:channel(email|sms)') // [!code focus]
notify(
@Param('channel') channel: 'email' | 'sms', // [!code focus]
@WorkflowParam('context') ctx: TApprovalContext,
) {
if (channel === 'email') {
sendEmail(ctx.userId, 'Your document was approved')
} else {
sendSms(ctx.userId, 'Your document was approved')
}
}
```
Reference parametric steps in schemas with concrete values:
```ts
@WorkflowSchema([
'review',
'approve',
{ id: 'notify/email' },
])
```
## What Steps Can Return
A step method can:
- **Return nothing** (`void`) — execution continues to the next step
- **Return `{ inputRequired }`** — pauses the workflow until input is provided (see [Pause & Resume](/wf/pause-resume))
- **Throw `StepRetriableError`** — pauses with an error that can be retried (see [Error Handling](/wf/errors))
- **Throw a regular error** — fails the workflow immediately
```ts
@Step('collect-data')
collectData(@WorkflowParam('input') input?: TFormData) {
if (!input) {
return { inputRequired: { fields: ['name', 'email'] } } // [!code focus]
}
// process input...
}
```
The `inputRequired` value can be anything your application understands — a boolean, a form schema object, or a string. Moost passes it through without interpretation.
## Reusing Steps Across Workflows
Steps are just methods on a controller. Multiple workflows can reference the same step by its ID:
```ts
@Controller()
export class SharedSteps {
@Step('send-notification')
sendNotification(@WorkflowParam('context') ctx: { email: string, message: string }) {
sendEmail(ctx.email, ctx.message)
}
}
// In another controller:
@WorkflowSchema(['validate', 'process', 'send-notification'])
// In yet another controller:
@WorkflowSchema(['review', 'approve', 'send-notification'])
```
::: info
Steps are resolved by their path (ID) through the Wooks router. As long as the step is registered (its controller is imported), any workflow schema can reference it.
:::
---
URL: "moost.org/wsapp"
LLMS_URL: "moost.org/wsapp.md"
---
# Quick Start
This guide shows you how to build a WebSocket application with Moost.
`@moostjs/event-ws` wraps [`@wooksjs/event-ws`](https://wooks.moost.org/wsapp/) and brings
decorator-based routing, dependency injection, interceptors, and pipes to your WebSocket handlers.
## Installation
```bash
npm install @moostjs/event-ws
```
::: tip
For HTTP-integrated mode (recommended), you also need `@moostjs/event-http`:
```bash
npm install @moostjs/event-ws @moostjs/event-http
```
:::
## Standalone Mode
The quickest way to get a WebSocket server running. All incoming connections are accepted automatically — no HTTP server or upgrade route is needed.
```ts
import { WsApp, Message, MessageData, ConnectionId, Connect } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
class ChatController {
@Connect()
onConnect(@ConnectionId() id: string) {
console.log(`Connected: ${id}`)
}
@Message('echo', '/echo')
echo(@MessageData() data: unknown) {
return data // replies to the client
}
}
new WsApp()
.controllers(ChatController)
.start(3000)
```
`WsApp` is a convenience class that extends `Moost` and sets up a standalone `MoostWs` adapter for you.
## HTTP-Integrated Mode
The recommended approach for production. The WebSocket server shares the HTTP port and requires an explicit upgrade route, giving you full control over which paths accept WebSocket connections and how they are authenticated.
### main.ts
```ts
import { MoostHttp } from '@moostjs/event-http'
import { MoostWs } from '@moostjs/event-ws'
import { Moost } from 'moost'
import { AppController } from './app.controller'
import { ChatController } from './chat.controller'
const app = new Moost()
const http = new MoostHttp()
const ws = new MoostWs({ httpApp: http.getHttpApp() })
app.adapter(http)
app.adapter(ws)
app.registerControllers(AppController, ChatController)
await http.listen(3000)
await app.init()
```
### app.controller.ts
```ts
import { Get, Upgrade } from '@moostjs/event-http'
import type { WooksWs } from '@moostjs/event-ws'
import { Controller, Inject } from 'moost'
@Controller()
export class AppController {
constructor(@Inject('WooksWs') private ws: WooksWs) {}
@Get('health')
health() {
return { status: 'ok' }
}
@Upgrade('ws')
upgrade() {
return this.ws.upgrade()
}
}
```
### chat.controller.ts
```ts
import { Message, MessageData, ConnectionId, useWsRooms } from '@moostjs/event-ws'
import { Controller, Param } from 'moost'
@Controller('chat')
export class ChatController {
@Message('join', ':room')
join(
@Param('room') room: string,
@ConnectionId() id: string,
@MessageData() data: { name: string },
) {
const { join, broadcast } = useWsRooms()
join()
broadcast('system', { text: `${data.name} joined` })
return { joined: true, room }
}
@Message('message', ':room')
onMessage(@MessageData() data: { from: string; text: string }) {
const { broadcast } = useWsRooms()
broadcast('message', data)
}
}
```
Clients connect to `ws://localhost:3000/ws` and send JSON messages routed by `event` and `path` fields. See the [wire protocol](./protocol) for message format details.
## Connecting a Client
Use `@wooksjs/ws-client` for a type-safe client with RPC support, reconnection, and push event handling:
```bash
npm install @wooksjs/ws-client
```
```ts
import { createWsClient } from '@wooksjs/ws-client'
const client = createWsClient('ws://localhost:3000/ws', {
reconnect: true,
rpcTimeout: 5000,
})
// RPC call — returns a promise with the server response
const result = await client.call('join', '/chat/general', { name: 'Alice' })
console.log(result) // { joined: true, room: 'general' }
// Listen for push messages
client.on('message', '/chat/general', ({ data }) => {
console.log(`${data.from}: ${data.text}`)
})
// Fire-and-forget
client.send('message', '/chat/general', { from: 'Alice', text: 'Hello!' })
```
See the full [client documentation](./client) for details.
## AI Agent Skill
Install the unified Moost AI skill for context-aware assistance in AI coding agents (Claude Code, Cursor, Windsurf, Codex, OpenCode):
```bash
npx skills add moostjs/moostjs
```
## What's Next?
- [Handlers](./handlers) — `@Message`, `@Connect`, `@Disconnect` decorators
- [Routing](./routing) — Event + path routing with parameters
- [Request Data](./request) — Resolver decorators for message data
- [Rooms & Broadcasting](./rooms) — Room management and message broadcasting
- [HTTP Integration](./integration) — Upgrade routes and shared HTTP context
- [Client](./client) — Full client API reference
- [Wire Protocol](./protocol) — JSON message format specification
- [Error Handling](./errors) — Error responses with `WsError`
- [Testing](./testing) — Unit-testing WebSocket handlers
---
URL: "moost.org/wsapp/client.html"
LLMS_URL: "moost.org/wsapp/client.md"
---
# Client
`@wooksjs/ws-client` is a type-safe WebSocket client with RPC support, automatic reconnection, and push event handling. It works in both browsers and Node.js.
For the full API reference, see the [Wooks WS Client Documentation](https://wooks.moost.org/wsapp/client.html).
## Installation
```bash
npm install @wooksjs/ws-client
```
For Node.js, you also need the `ws` package:
```bash
npm install @wooksjs/ws-client ws
```
## Quick Overview
```ts
import { createWsClient } from '@wooksjs/ws-client'
const client = createWsClient('ws://localhost:3000/ws', {
reconnect: true,
rpcTimeout: 5000,
})
```
For Node.js, pass the `ws` constructor via the `_WebSocket` option:
```ts
import WebSocket from 'ws'
const client = createWsClient('ws://localhost:3000/ws', {
_WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
})
```
The client provides three main communication patterns:
| Method | Description |
|--------|-------------|
| `send(event, path, data?)` | Fire-and-forget — no reply expected |
| `call(event, path, data?)` | RPC — returns a promise with server response |
| `on(event, path, handler)` | Listen for server-initiated push messages |
## Example
```ts
// RPC — join a room and get a response
const result = await client.call<{ joined: boolean }>(
'join', '/chat/general', { name: 'Alice' },
)
// Listen for broadcasts from the room
client.on('message', '/chat/general', ({ data }) => {
console.log(`${data.from}: ${data.text}`)
})
// Fire-and-forget — send a chat message
client.send('message', '/chat/general', { from: 'Alice', text: 'Hello!' })
```
## Key Features
- **RPC with correlation** — `call()` auto-generates IDs and matches replies via promises
- **Push listeners** — `on()` supports exact path and wildcard (`/chat/*`) matching
- **Auto-reconnection** — configurable exponential or linear backoff, queues messages while disconnected
- **Error handling** — `call()` rejects with `WsClientError` (timeout, server errors)
- **Lifecycle hooks** — `onOpen()`, `onClose()`, `onError()`, `onReconnect()`
- **Subscriptions** — `subscribe()` with auto-resubscribe on reconnect
See the [full client documentation](https://wooks.moost.org/wsapp/client.html) for detailed API reference, configuration options, reconnection strategies, and error handling.
---
URL: "moost.org/wsapp/errors.html"
LLMS_URL: "moost.org/wsapp/errors.md"
---
# Error Handling
Moost WS uses `WsError` for structured error responses. When a handler throws a `WsError`, the server sends an error reply to the client with the specified code and message.
[[toc]]
## WsError
`WsError` is the WebSocket equivalent of `HttpError`. It carries a numeric error code and a message string.
```ts
import { WsError } from '@moostjs/event-ws'
throw new WsError(400, 'Name is required')
throw new WsError(401, 'Unauthorized')
throw new WsError(409, 'Name already taken')
```
### In Message Handlers
```ts
import { Message, MessageData, ConnectionId } from '@moostjs/event-ws'
import { WsError } from '@moostjs/event-ws'
import { Controller, Param } from 'moost'
@Controller('chat')
export class ChatController {
@Message('join', ':room')
join(
@ConnectionId() id: string,
@MessageData() data: { name: string },
) {
if (!data?.name || data.name.trim().length === 0) {
throw new WsError(400, 'Name is required') // [!code focus]
}
if (isNameTaken(data.name, id)) {
throw new WsError(409, `Name "${data.name}" is already taken`) // [!code focus]
}
// ... join logic
}
}
```
When the client sent the message with an `id` (RPC), they receive:
```json
{ "id": 1, "error": { "code": 409, "message": "Name \"Alice\" is already taken" } }
```
### In Upgrade Handlers
`WsError` can also be thrown in `@Upgrade` handlers to reject WebSocket connections:
```ts
import { Upgrade } from '@moostjs/event-http'
import { WsError } from '@moostjs/event-ws'
import { useHeaders } from '@wooksjs/event-http'
@Upgrade('ws/admin')
upgradeAdmin() {
const headers = useHeaders()
if (headers.authorization !== 'Bearer admin-secret') {
throw new WsError(401, 'Unauthorized') // [!code focus]
}
return this.ws.upgrade()
}
```
### In Connect Handlers
Throwing in a `@Connect` handler closes the connection:
```ts
@Connect()
onConnect(@ConnectionId() id: string) {
if (tooManyConnections()) {
throw new WsError(503, 'Server at capacity') // [!code focus]
}
}
```
## Error Codes
You can use any numeric code. Common conventions:
| Code | Meaning |
|------|---------|
| 400 | Bad request / validation error |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not found (auto-sent for unmatched routes) |
| 409 | Conflict |
| 429 | Too many requests |
| 500 | Internal server error (auto-sent for unhandled exceptions) |
| 503 | Service unavailable |
## Unhandled Errors
If a handler throws a non-`WsError` exception, the server:
1. Logs the error server-side
2. Sends a generic `500` error reply (for RPC messages):
```json
{ "id": 1, "error": { "code": 500, "message": "Internal Error" } }
```
The actual error details are **not** exposed to the client for security.
## Using Interceptors for Error Handling
You can use Moost's interceptor system to create centralized error handling:
```ts
import { defineErrorInterceptor, TInterceptorPriority } from 'moost'
const wsErrorHandler = defineErrorInterceptor((error, reply) => {
// log, transform, or suppress errors
console.error('WS handler error:', error)
}, TInterceptorPriority.CATCH_ERROR)
@Controller('chat')
@Intercept(wsErrorHandler)
export class ChatController {
// ...
}
```
## Client-Side Error Handling
On the client, use `WsClientError` to handle RPC errors:
```ts
import { WsClientError } from '@wooksjs/ws-client'
try {
await client.call('join', '/chat/general', { name: 'Alice' })
} catch (err) {
if (err instanceof WsClientError) {
console.log(`Error ${err.code}: ${err.message}`)
}
}
```
See the [client documentation](./client#error-handling) for more details.
---
URL: "moost.org/wsapp/handlers.html"
LLMS_URL: "moost.org/wsapp/handlers.md"
---
# Handlers
Moost WS provides three decorator types for handling WebSocket events: message handlers, connection handlers, and disconnection handlers.
[[toc]]
## Message Handlers
The `@Message` decorator registers a handler for routed WebSocket messages. Each message from the client carries an `event` type and a `path` — the decorator matches on both.
```ts
import { Message, MessageData } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
export class EchoController {
@Message('echo', '/echo')
echo(@MessageData() data: unknown) {
return data
}
}
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `event` | `string` | The message event type to match (e.g. `"message"`, `"join"`, `"rpc"`) |
| `path` | `string` (optional) | Route path with optional parameters (e.g. `"/chat/:room"`) |
When `path` is omitted, the handler method name is used as the path.
### Return Values
The return value of a message handler is sent back to the client as a reply — but **only** when the client sent a correlation ID (i.e. used `call()` instead of `send()`).
```ts
@Message('query', '/status')
getStatus() {
return { online: true, uptime: process.uptime() }
}
```
If the client sends `{ event: "query", path: "/status", id: 1 }`, they receive:
```json
{ "id": 1, "data": { "online": true, "uptime": 42.5 } }
```
If the client sends without an `id` (fire-and-forget), the return value is ignored.
### Multiple Events on the Same Path
You can register multiple event types on the same path:
```ts
@Controller('chat')
export class ChatController {
@Message('join', ':room')
join(@Param('room') room: string) { /* ... */ }
@Message('leave', ':room')
leave(@Param('room') room: string) { /* ... */ }
@Message('message', ':room')
message(@Param('room') room: string) { /* ... */ }
}
```
## Connection Handler
The `@Connect` decorator runs when a new WebSocket connection is established. It executes inside the connection context, where you can access the connection ID and HTTP upgrade headers.
```ts
import { Connect, ConnectionId } from '@moostjs/event-ws'
import { useHeaders } from '@wooksjs/event-http'
import { Controller } from 'moost'
@Controller()
export class LifecycleController {
@Connect()
onConnect(@ConnectionId() id: string) {
const headers = useHeaders()
console.log(`New connection ${id} from ${headers['user-agent']}`)
}
}
```
::: warning
If the `@Connect` handler throws an error or returns a rejected promise, the connection is closed immediately.
:::
## Disconnection Handler
The `@Disconnect` decorator runs when a WebSocket connection closes. Use it for cleanup tasks like removing user state or notifying other clients.
```ts
import { Disconnect, ConnectionId } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
export class LifecycleController {
@Disconnect()
onDisconnect(@ConnectionId() id: string) {
console.log(`Connection ${id} closed`)
// clean up user state, notify others, etc.
}
}
```
::: tip
Room membership is automatically cleaned up when a connection closes — you don't need to manually leave rooms in the disconnect handler.
:::
## Handler Context
All three handler types participate in the full Moost event lifecycle:
1. **Scope registration** — `FOR_EVENT` scoped instances are created
2. **Interceptor before** — Runs before args are resolved; can short-circuit the handler
3. **Argument resolution** — Pipes resolve, transform, and validate parameters
4. **Handler execution** — Your method body runs
5. **Interceptor after/error** — Post-processing or error handling
6. **Scope cleanup** — Event-scoped instances are released
This means you can use all standard Moost features with WebSocket handlers:
```ts
import { Message, MessageData } from '@moostjs/event-ws'
import { Controller, Intercept, Validate } from 'moost'
import { AuthGuard } from './auth.guard'
@Controller('admin')
@Intercept(AuthGuard)
export class AdminController {
@Message('broadcast', '/announce')
announce(@MessageData() @Validate() data: AnnounceDto) {
// protected by AuthGuard and validated
}
}
```
## One Controller, Multiple Handler Types
A single controller can contain all three handler types alongside regular HTTP handlers (if you're using both adapters):
```ts
@Controller('chat')
export class ChatController {
@Connect()
onConnect(@ConnectionId() id: string) { /* ... */ }
@Disconnect()
onDisconnect(@ConnectionId() id: string) { /* ... */ }
@Message('join', ':room')
join(@Param('room') room: string) { /* ... */ }
@Message('message', ':room')
message(@Param('room') room: string) { /* ... */ }
}
```
---
URL: "moost.org/wsapp/integration.html"
LLMS_URL: "moost.org/wsapp/integration.md"
---
# HTTP Integration
The recommended way to run WebSocket in production is HTTP-integrated mode, where the WS server shares the HTTP port. This gives you control over which paths accept WebSocket connections, enables authentication during the upgrade handshake, and keeps everything on a single port.
[[toc]]
## Setup
Pass the HTTP app instance when creating the WS adapter:
```ts
import { MoostHttp } from '@moostjs/event-http'
import { MoostWs } from '@moostjs/event-ws'
import { Moost } from 'moost'
const app = new Moost()
const http = new MoostHttp()
const ws = new MoostWs({ httpApp: http.getHttpApp() }) // [!code focus]
app.adapter(http)
app.adapter(ws)
app.registerControllers(/* ... */)
await http.listen(3000)
await app.init()
```
The `MoostWs` constructor accepts either a `WooksHttp` instance directly or any object with a `getHttpApp()` method (like `MoostHttp`).
## Upgrade Routes
In HTTP-integrated mode, WebSocket connections require an explicit upgrade route. Use the `@Upgrade` decorator from `@moostjs/event-http`:
```ts
import { Upgrade } from '@moostjs/event-http'
import type { WooksWs } from '@moostjs/event-ws'
import { Controller, Inject } from 'moost'
@Controller()
export class AppController {
constructor(@Inject('WooksWs') private ws: WooksWs) {} // [!code focus]
@Upgrade('ws') // [!code focus]
upgrade() { // [!code focus]
return this.ws.upgrade() // [!code focus]
} // [!code focus]
}
```
Clients connect to `ws://localhost:3000/ws`. The `@Upgrade('ws')` route handles the HTTP 101 upgrade handshake, and `this.ws.upgrade()` completes the WebSocket connection.
::: tip DI with string keys
Use `@Inject('WooksWs')` (string key) rather than `@Inject(WooksWs)` (class reference) for the constructor parameter. This avoids module initialization order issues when running with tsx/esbuild, which doesn't emit `design:paramtypes` metadata.
:::
### Multiple Upgrade Paths
You can define multiple upgrade routes for different purposes:
```ts
@Controller()
export class AppController {
constructor(@Inject('WooksWs') private ws: WooksWs) {}
@Upgrade('ws')
publicUpgrade() {
return this.ws.upgrade()
}
@Upgrade('ws/admin')
adminUpgrade() {
// authenticate before upgrading
const headers = useHeaders()
if (headers.authorization !== 'Bearer admin-secret') {
throw new WsError(401, 'Unauthorized')
}
return this.ws.upgrade()
}
}
```
## Authentication
The upgrade route runs in a full HTTP context, so you can authenticate using HTTP headers, cookies, or query parameters before accepting the WebSocket connection:
### Header-Based Auth
```ts
import { Upgrade } from '@moostjs/event-http'
import type { WooksWs } from '@moostjs/event-ws'
import { WsError } from '@moostjs/event-ws'
import { useHeaders } from '@wooksjs/event-http'
import { Controller, Inject } from 'moost'
@Controller()
export class SecureController {
constructor(@Inject('WooksWs') private ws: WooksWs) {}
@Upgrade('ws/secure')
secureUpgrade() {
const headers = useHeaders()
const token = headers.authorization
if (!token || !verifyToken(token)) {
throw new WsError(401, 'Invalid or missing token')
}
return this.ws.upgrade()
}
}
```
### Cookie-Based Auth
```ts
import { useCookies } from '@wooksjs/event-http'
@Upgrade('ws/session')
sessionUpgrade() {
const cookies = useCookies()
const sessionId = cookies.get('session_id')
if (!sessionId || !isValidSession(sessionId)) {
throw new WsError(401, 'Invalid session')
}
return this.ws.upgrade()
}
```
### Using Moost Interceptors
You can also protect upgrade routes with standard Moost interceptors:
```ts
import { Upgrade } from '@moostjs/event-http'
import { Controller, Inject, Intercept } from 'moost'
import { AuthGuard } from './auth.guard'
@Controller()
export class SecureController {
constructor(@Inject('WooksWs') private ws: WooksWs) {}
@Upgrade('ws/protected')
@Intercept(AuthGuard) // [!code focus]
protectedUpgrade() {
return this.ws.upgrade()
}
}
```
## HTTP Context in WS Handlers
After the upgrade, WebSocket handlers can access the original HTTP upgrade request through the parent context chain:
```ts
import { Connect, ConnectionId } from '@moostjs/event-ws'
import { useHeaders, useRequest, useCookies } from '@wooksjs/event-http'
@Connect()
onConnect(@ConnectionId() id: string) {
const { url } = useRequest() // upgrade URL
const headers = useHeaders() // upgrade request headers
const cookies = useCookies() // cookies from upgrade request
console.log('Upgrade URL:', url)
console.log('User-Agent:', headers['user-agent'])
}
```
This works in all handler types (`@Connect`, `@Disconnect`, `@Message`) because the connection context carries a reference to the parent HTTP context.
## DI: Injecting Adapter Instances
The `MoostWs` adapter registers both class reference and string keys in the DI container:
| Key | Resolves To |
|-----|-------------|
| `MoostWs` / `'MoostWs'` | The `MoostWs` adapter instance |
| `WooksWs` / `'WooksWs'` | The underlying `WooksWs` instance |
Use string keys for reliability:
```ts
@Controller()
export class MyController {
constructor(
@Inject('MoostWs') private wsAdapter: MoostWs,
@Inject('WooksWs') private wsApp: WooksWs,
) {}
}
```
## Mixing HTTP and WS Handlers
A single application can serve both HTTP and WebSocket handlers. Controllers are registered once, and each adapter picks up only the decorators it understands:
```ts
import { Get } from '@moostjs/event-http'
import { Message, MessageData } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller('api')
export class ApiController {
// HTTP handler — picked up by MoostHttp
@Get('status')
getStatus() {
return { online: true }
}
// WS handler — picked up by MoostWs
@Message('query', '/status')
wsStatus() {
return { online: true }
}
}
```
---
URL: "moost.org/wsapp/protocol.html"
LLMS_URL: "moost.org/wsapp/protocol.md"
---
# Wire Protocol
Moost WS uses a simple JSON-over-WebSocket protocol. Messages are plain JSON objects sent as text frames — no custom framing, no binary encoding.
For the full Wooks protocol reference, see the [Wooks Wire Protocol Documentation](https://wooks.moost.org/wsapp/protocol.html).
[[toc]]
## Message Types
### Client to Server
Every client message has an `event` and `path` for routing, optional `data` for payload, and an optional `id` for RPC correlation:
```ts
interface WsClientMessage {
event: string // Router method (e.g. "message", "join", "query")
path: string // Route path (e.g. "/chat/general")
data?: unknown // Payload
id?: string | number // Correlation ID — present for RPC, absent for fire-and-forget
}
```
**Fire-and-forget** (no reply expected):
```json
{ "event": "message", "path": "/chat/general", "data": { "text": "Hello!" } }
```
**RPC** (reply expected):
```json
{ "event": "join", "path": "/chat/general", "data": { "name": "Alice" }, "id": 1 }
```
### Server to Client: Reply
Sent in response to a client message that included an `id`. Exactly one reply per request.
```ts
interface WsReplyMessage {
id: string | number // Matches client's correlation ID
data?: unknown // Handler return value
error?: { code: number; message: string } // Error details (if handler threw)
}
```
**Success reply:**
```json
{ "id": 1, "data": { "joined": true, "room": "general" } }
```
**Error reply:**
```json
{ "id": 1, "error": { "code": 400, "message": "Name is required" } }
```
### Server to Client: Push
Server-initiated messages from broadcasts, subscriptions, or direct sends. No `id` field.
```ts
interface WsPushMessage {
event: string // Event type
path: string // Concrete path
params?: Record // Route params extracted by router
data?: unknown // Payload
}
```
**Push example:**
```json
{
"event": "message",
"path": "/chat/general",
"data": { "from": "Alice", "text": "Hello!" }
}
```
## Routing Logic
Messages are routed by two dimensions:
1. **Event** — the `event` field acts as the "method" (like HTTP GET/POST)
2. **Path** — the `path` field acts as the URL path
The router matches against registered handlers:
```ts
// Server-side handler
@Message('join', '/chat/:room')
join(@Param('room') room: string) { /* ... */ }
```
```json
// Client message that matches
{ "event": "join", "path": "/chat/general", "data": { "name": "Alice" }, "id": 1 }
```
The `event` must match exactly. The `path` supports parametric patterns (`:param`) and wildcards (`*`).
## Error Responses
When a handler throws an error, the server replies with an error object (only for RPC messages with an `id`):
| Scenario | Code | Message |
|----------|------|---------|
| Handler throws `WsError(code, msg)` | Custom code | Custom message |
| No matching handler found | 404 | `"Not found"` |
| Unhandled exception in handler | 500 | `"Internal Error"` |
```json
{ "id": 1, "error": { "code": 404, "message": "Not found" } }
```
For fire-and-forget messages (no `id`), errors are logged server-side but not sent to the client.
## Heartbeat
The server sends periodic WebSocket `ping` frames to detect stale connections. Clients that don't respond with a `pong` within the timeout are disconnected.
Configure the heartbeat interval:
```ts
const ws = new MoostWs({
httpApp: http.getHttpApp(),
wooksWs: {
heartbeatInterval: 30000, // milliseconds (default: 30000)
},
})
```
Set to `0` to disable heartbeat.
## Custom Serialization
Both server and client support pluggable serialization for formats like MessagePack or CBOR:
**Server:**
```ts
const ws = new MoostWs({
wooksWs: {
messageParser: (raw: string) => myCustomParse(raw),
messageSerializer: (msg: unknown) => myCustomSerialize(msg),
},
})
```
**Client:**
```ts
const client = createWsClient('ws://localhost:3000/ws', {
messageParser: (raw: string) => myCustomParse(raw),
messageSerializer: (msg: unknown) => myCustomSerialize(msg),
})
```
Both sides must use the same serialization format.
---
URL: "moost.org/wsapp/request.html"
LLMS_URL: "moost.org/wsapp/request.md"
---
# Request Data
Moost WS provides resolver decorators to extract data from WebSocket messages and connections.
These decorators can be applied to handler method arguments.
Additionally, you can use composable functions from Wooks directly inside handlers.
For details, see the [Wooks WS Composables](https://wooks.moost.org/wsapp/composables.html) documentation.
::: info
To learn more about the foundation of resolver decorators, read the [Moost Resolvers Documentation](/moost/pipes/resolve).
:::
[[toc]]
## Message Data
### MessageData
The `@MessageData` decorator resolves the parsed message payload. This is the `data` field from the client message after JSON parsing.
```ts
import { Message, MessageData } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
export class ChatController {
@Message('message', '/chat/:room')
onMessage(@MessageData() data: { from: string; text: string }) { // [!code focus]
console.log(`${data.from}: ${data.text}`)
}
}
```
Only available in `@Message` handlers.
### RawMessage
The `@RawMessage` decorator resolves the raw message before JSON parsing — a `Buffer` or `string`.
```ts
import { Message, RawMessage } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
export class DebugController {
@Message('debug', '/raw')
onRaw(@RawMessage() raw: Buffer | string) { // [!code focus]
console.log('Raw message:', raw.toString())
}
}
```
Only available in `@Message` handlers.
### MessageId
The `@MessageId` decorator resolves the message correlation ID. This is `undefined` for fire-and-forget messages and a `string | number` for RPC calls.
```ts
import { Message, MessageId, MessageData } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
export class RpcController {
@Message('query', '/info')
info(
@MessageId() messageId: string | number | undefined, // [!code focus]
@MessageData() data: unknown,
) {
console.log('Correlation ID:', messageId)
return { timestamp: Date.now() }
}
}
```
Only available in `@Message` handlers.
### MessageType
The `@MessageType` decorator resolves the event type string from the message.
```ts
import { Message, MessageType } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
export class LogController {
@Message('*', '/log')
onAny(@MessageType() event: string) { // [!code focus]
console.log('Event type:', event)
}
}
```
Only available in `@Message` handlers.
### MessagePath
The `@MessagePath` decorator resolves the concrete message path (after routing).
```ts
import { Message, MessagePath } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
export class LogController {
@Message('action', '/game/:id')
onAction(@MessagePath() path: string) { // [!code focus]
console.log('Message path:', path) // e.g. "/game/42"
}
}
```
Only available in `@Message` handlers.
## Connection Info
### ConnectionId
The `@ConnectionId` decorator resolves the unique connection identifier (UUID). Available in **all** WebSocket handler types — message, connect, and disconnect.
```ts
import { Message, Connect, Disconnect, ConnectionId } from '@moostjs/event-ws'
import { Controller } from 'moost'
@Controller()
export class TrackingController {
@Connect()
onConnect(@ConnectionId() id: string) { // [!code focus]
console.log(`Connected: ${id}`)
}
@Message('ping', '/ping')
ping(@ConnectionId() id: string) { // [!code focus]
return { pong: true, connectionId: id }
}
@Disconnect()
onDisconnect(@ConnectionId() id: string) { // [!code focus]
console.log(`Disconnected: ${id}`)
}
}
```
## Route Parameters
### Param
The `@Param` decorator resolves named route parameters from the message path. This is the same decorator used in HTTP routing.
```ts
import { Message, MessageData } from '@moostjs/event-ws'
import { Controller, Param } from 'moost'
@Controller('chat')
export class ChatController {
@Message('message', ':room')
onMessage(
@Param('room') room: string, // [!code focus]
@MessageData() data: { text: string },
) {
console.log(`[${room}] ${data.text}`)
}
}
```
For a message with path `/chat/general`, `room` resolves to `"general"`.
### Params
Use `@Params()` to get all route parameters as an object:
```ts
import { Message } from '@moostjs/event-ws'
import { Controller, Params } from 'moost'
@Controller()
export class GameController {
@Message('move', '/game/:gameId/player/:playerId')
onMove(@Params() params: { gameId: string; playerId: string }) { // [!code focus]
console.log(params) // { gameId: '1', playerId: 'alice' }
}
}
```
## HTTP Context in WebSocket Handlers
When using HTTP-integrated mode, the original HTTP upgrade request context is available in WebSocket handlers through the parent event context chain. You can access HTTP composables like `useHeaders`, `useRequest`, and `useCookies`:
```ts
import { Connect, ConnectionId } from '@moostjs/event-ws'
import { useHeaders, useRequest } from '@wooksjs/event-http'
import { Controller } from 'moost'
@Controller()
export class ConnectionController {
@Connect()
onConnect(@ConnectionId() id: string) {
const { url } = useRequest()
const headers = useHeaders()
console.log('Upgrade URL:', url)
console.log('User-Agent:', headers['user-agent'])
console.log('Origin:', headers.origin)
}
}
```
::: tip
HTTP composables read from the upgrade request that initiated the WebSocket connection. They are read-only — response composables like `useResponse()` are not available in WS handlers.
:::
## Summary
| Decorator | Returns | Available In |
|-----------|---------|-------------|
| `@MessageData()` | Parsed message payload | `@Message` |
| `@RawMessage()` | Raw `Buffer \| string` | `@Message` |
| `@MessageId()` | Correlation ID `string \| number \| undefined` | `@Message` |
| `@MessageType()` | Event type `string` | `@Message` |
| `@MessagePath()` | Concrete message path `string` | `@Message` |
| `@ConnectionId()` | Connection UUID `string` | All handlers |
| `@Param(name)` | Named route parameter `string` | `@Message` |
| `@Params()` | All route parameters `object` | `@Message` |
---
URL: "moost.org/wsapp/rooms.html"
LLMS_URL: "moost.org/wsapp/rooms.md"
---
# Rooms & Broadcasting
Rooms let you group connections and broadcast messages to all members. A connection can join multiple rooms. Room names are strings — by default, the current message path is used as the room name.
[[toc]]
## Room Management
Use the `useWsRooms()` composable inside message handlers to join, leave, and broadcast to rooms.
### Joining a Room
```ts
import { Message, MessageData, ConnectionId } from '@moostjs/event-ws'
import { useWsRooms } from '@moostjs/event-ws'
import { Controller, Param } from 'moost'
@Controller('chat')
export class ChatController {
@Message('join', ':room')
join(
@Param('room') room: string,
@ConnectionId() id: string,
@MessageData() data: { name: string },
) {
const { join, broadcast, rooms } = useWsRooms()
join() // joins the current path as room (e.g. "/chat/general")
broadcast('system', { text: `${data.name} joined` })
return { joined: true, room, rooms: rooms() }
}
}
```
`join()` without arguments uses the current message path as the room name. You can pass a custom room name:
```ts
join('/custom-room-name')
```
### Leaving a Room
```ts
@Message('leave', ':room')
leave(@Param('room') room: string) {
const { leave, broadcast, rooms } = useWsRooms()
broadcast('system', { text: 'Someone left' })
leave() // leaves the current path room
return { left: true, rooms: rooms() }
}
```
::: tip
When a connection closes, it is automatically removed from all rooms. You don't need to manually leave rooms in `@Disconnect` handlers.
:::
### Listing Rooms
```ts
const { rooms } = useWsRooms()
const joinedRooms = rooms() // string[]
```
## Broadcasting
### Room Broadcast
`broadcast()` from `useWsRooms()` sends a message to all connections in the current room, **excluding the sender** by default:
```ts
@Message('message', ':room')
onMessage(@MessageData() data: { from: string; text: string }) {
const { broadcast } = useWsRooms()
broadcast('message', { from: data.from, text: data.text })
}
```
Recipients receive a push message:
```json
{
"event": "message",
"path": "/chat/general",
"data": { "from": "Alice", "text": "Hello!" }
}
```
#### Broadcast Options
```ts
broadcast('message', data, {
room: '/custom-room', // target a different room
excludeSelf: false, // include the sender (default: true)
})
```
### Direct Send
Use `useWsConnection()` to send a message to the current connection:
```ts
import { useWsConnection } from '@moostjs/event-ws'
@Message('notify', '/self')
notify() {
const { send } = useWsConnection()
send('notification', '/alerts', { text: 'Just for you' })
}
```
### Server-Wide Broadcast
Use `useWsServer()` to broadcast to **all** connected clients, regardless of room membership:
```ts
import { useWsServer } from '@moostjs/event-ws'
@Message('admin', '/announce')
announce(@MessageData() data: { text: string }) {
const server = useWsServer()
server.broadcast('announcement', '/announce', { text: data.text })
return { announced: true }
}
```
### Send to a Specific Connection
```ts
import { useWsServer } from '@moostjs/event-ws'
@Message('dm', '/direct')
directMessage(@MessageData() data: { targetId: string; text: string }) {
const server = useWsServer()
const target = server.getConnection(data.targetId)
if (target) {
target.send('dm', '/direct', { text: data.text })
}
}
```
## Server Queries
### All Connections
```ts
const server = useWsServer()
const allConnections = server.connections() // Map
console.log('Total:', allConnections.size)
```
### Room Connections
```ts
const server = useWsServer()
const roomConns = server.roomConnections('/chat/general') // Set
console.log('Users in room:', roomConns.size)
```
### Single Connection by ID
```ts
const server = useWsServer()
const conn = server.getConnection(connectionId) // WsConnection | undefined
```
## Multi-Instance Broadcasting
For horizontal scaling (multiple server instances), implement the `WsBroadcastTransport` interface to relay room broadcasts across instances via Redis, NATS, or any pub/sub system:
```ts
import type { WsBroadcastTransport } from '@moostjs/event-ws'
class RedisBroadcastTransport implements WsBroadcastTransport {
publish(channel: string, payload: string) {
redis.publish(channel, payload)
}
subscribe(channel: string, handler: (payload: string) => void) {
redis.subscribe(channel, handler)
}
unsubscribe(channel: string) {
redis.unsubscribe(channel)
}
}
```
Pass it when creating the WS adapter:
```ts
const ws = new MoostWs({
httpApp: http.getHttpApp(),
wooksWs: {
broadcastTransport: new RedisBroadcastTransport(),
},
})
```
Channels follow the pattern `ws:room:`.
## Composable Reference
| Composable | Method | Description |
|-----------|--------|-------------|
| `useWsRooms()` | `join(room?)` | Join a room (default: current path) |
| | `leave(room?)` | Leave a room |
| | `broadcast(event, data?, opts?)` | Broadcast to room members |
| | `rooms()` | List joined rooms |
| `useWsServer()` | `broadcast(event, path, data?)` | Broadcast to all clients |
| | `connections()` | Get all connections |
| | `roomConnections(room)` | Get connections in a room |
| | `getConnection(id)` | Get connection by ID |
| `useWsConnection()` | `send(event, path, data?)` | Send to current connection |
| | `id` | Connection UUID |
| | `close()` | Close the connection |
---
URL: "moost.org/wsapp/routing.html"
LLMS_URL: "moost.org/wsapp/routing.md"
---
# Routing
WebSocket message routing in Moost uses a two-dimensional scheme: messages are matched by both **event type** and **path**. This is powered by the same router that handles HTTP routes in Wooks.
[[toc]]
## Event + Path Routing
Every client message carries two routing fields:
```json
{ "event": "message", "path": "/chat/general", "data": { "text": "Hello!" } }
```
The `@Message` decorator matches both:
```ts
@Message('message', '/chat/general')
onMessage(@MessageData() data: { text: string }) {
// handles event="message" at path="/chat/general"
}
```
This allows you to use the same path with different event types:
```ts
@Controller('chat')
export class ChatController {
@Message('join', ':room') // event="join", path="/chat/:room"
join() { /* ... */ }
@Message('leave', ':room') // event="leave", path="/chat/:room"
leave() { /* ... */ }
@Message('message', ':room') // event="message", path="/chat/:room"
message() { /* ... */ }
}
```
## Controller Prefixes
The `@Controller` prefix is prepended to message handler paths, just like with HTTP routes:
```ts
@Controller('game')
export class GameController {
@Message('move', 'board/:id')
// effective path: /game/board/:id
onMove(@Param('id') id: string) { /* ... */ }
}
```
Nested controllers with `@ImportController` also compose prefixes:
```ts
@Controller('v2')
export class V2Controller {
@ImportController(() => GameController)
game!: GameController
// GameController routes become /v2/game/board/:id
}
```
## Parametric Routes
Route parameters use the colon (`:`) syntax, identical to HTTP routing:
```ts
@Controller('chat')
export class ChatController {
// Single parameter
@Message('join', ':room')
join(@Param('room') room: string) { /* ... */ }
// Multiple parameters
@Message('dm', ':sender/:receiver')
directMessage(
@Param('sender') sender: string,
@Param('receiver') receiver: string,
) { /* ... */ }
}
```
Parameters are extracted from the concrete path in the client message:
```json
{ "event": "dm", "path": "/chat/alice/bob", "data": { "text": "Hi!" } }
```
### All Route Parameters
Use `@Params()` to get all parameters as an object:
```ts
import { Params } from 'moost'
@Message('action', ':type/:id')
handle(@Params() params: { type: string; id: string }) {
console.log(params) // { type: 'move', id: '42' }
}
```
## Wildcards
Wildcards (`*`) match zero or more characters:
```ts
@Message('log', '/events/*')
handleAllEvents(@Param('*') subPath: string) {
// matches /events/user/login, /events/system/error, etc.
}
```
## Path Omission
When `path` is omitted from `@Message`, the handler method name is used as the path:
```ts
@Controller('api')
export class ApiController {
@Message('query')
status() {
// effective path: /api/status (method name "status" becomes the path)
return { ok: true }
}
}
```
---
URL: "moost.org/wsapp/testing.html"
LLMS_URL: "moost.org/wsapp/testing.md"
---
# Testing
`@moostjs/event-ws` re-exports test helpers from `@wooksjs/event-ws` that let you unit-test WebSocket handlers and composables without starting a real server.
[[toc]]
## Test Helpers
Two context factories match the two context layers:
1. **`prepareTestWsConnectionContext`** — for testing `@Connect`/`@Disconnect` handler logic
2. **`prepareTestWsMessageContext`** — for testing `@Message` handler logic (includes a parent connection context)
Both return a **runner function** `(cb: () => T) => T` that executes a callback inside a fully initialized event context.
## Testing Message Handlers
Use `prepareTestWsMessageContext` to create a mock message context with event, path, data, and optional route parameters:
```ts
import { describe, it, expect } from 'vitest'
import {
prepareTestWsMessageContext,
useWsMessage,
useWsConnection,
} from '@moostjs/event-ws'
describe('ChatController', () => {
it('should access message data', () => {
const runInCtx = prepareTestWsMessageContext({
event: 'message',
path: '/chat/general',
data: { from: 'Alice', text: 'Hello!' },
messageId: 1,
})
runInCtx(() => {
const { data, id, path, event } = useWsMessage<{ from: string; text: string }>()
expect(data.from).toBe('Alice')
expect(data.text).toBe('Hello!')
expect(id).toBe(1)
expect(path).toBe('/chat/general')
expect(event).toBe('message')
})
})
it('should access connection id', () => {
const runInCtx = prepareTestWsMessageContext({
event: 'join',
path: '/chat/general',
data: { name: 'Alice' },
id: 'conn-123', // custom connection ID
})
runInCtx(() => {
const { id } = useWsConnection()
expect(id).toBe('conn-123')
})
})
})
```
### Options
```ts
interface TTestWsMessageContext {
event: string // required — message event type
path: string // required — message route path
data?: unknown // parsed message payload
messageId?: string | number // correlation ID
rawMessage?: Buffer | string // raw message before parsing
id?: string // connection ID (default: 'test-conn-id')
params?: Record // pre-set route parameters
parentCtx?: EventContext // optional parent context (e.g. HTTP)
}
```
## Testing Connection Handlers
Use `prepareTestWsConnectionContext` for connection lifecycle handler logic:
```ts
import { describe, it, expect } from 'vitest'
import {
prepareTestWsConnectionContext,
useWsConnection,
} from '@moostjs/event-ws'
describe('LifecycleController', () => {
it('should access connection info', () => {
const runInCtx = prepareTestWsConnectionContext({
id: 'conn-456',
})
runInCtx(() => {
const { id } = useWsConnection()
expect(id).toBe('conn-456')
})
})
})
```
### Options
```ts
interface TTestWsConnectionContext {
id?: string // connection ID (default: 'test-conn-id')
params?: Record // pre-set route parameters
parentCtx?: EventContext // optional parent context
}
```
## Testing with Route Parameters
Pre-set route parameters to test handlers that use `@Param` or `useRouteParams`:
```ts
import { prepareTestWsMessageContext } from '@moostjs/event-ws'
import { useRouteParams } from '@wooksjs/event-core'
it('should resolve route params', () => {
const runInCtx = prepareTestWsMessageContext({
event: 'join',
path: '/chat/rooms/lobby',
params: { room: 'lobby' },
data: { name: 'Alice' },
})
runInCtx(() => {
const { get } = useRouteParams<{ room: string }>()
expect(get('room')).toBe('lobby')
})
})
```
## Testing with HTTP Parent Context
When testing handlers that access HTTP composables (like `useHeaders` from the upgrade request), pass a parent HTTP context:
```ts
import { EventContext } from '@wooksjs/event-core'
import { prepareTestWsMessageContext, currentConnection } from '@moostjs/event-ws'
it('should have access to parent HTTP context', () => {
const httpCtx = new EventContext({ logger: console as any })
// Seed httpCtx with HTTP-specific data if needed
const runInCtx = prepareTestWsMessageContext({
event: 'test',
path: '/test',
parentCtx: httpCtx,
})
runInCtx(() => {
const connCtx = currentConnection()
expect(connCtx.parent).toBe(httpCtx)
})
})
```
## Testing Handler Functions Directly
You can test your handler methods by calling them inside the test context:
```ts
import { prepareTestWsMessageContext, useWsRooms } from '@moostjs/event-ws'
// Your handler function (extracted from controller for testing)
function handleJoin(room: string, name: string) {
const { join, broadcast, rooms } = useWsRooms()
join()
broadcast('system', { text: `${name} joined` })
return { joined: true, room, rooms: rooms() }
}
it('should join a room and return room list', () => {
const runInCtx = prepareTestWsMessageContext({
event: 'join',
path: '/chat/general',
data: { name: 'Alice' },
})
const result = runInCtx(() => handleJoin('general', 'Alice'))
expect(result.joined).toBe(true)
expect(result.room).toBe('general')
})
```
::: warning
Testing composables that depend on adapter state (`useWsRooms`, `useWsServer`) requires the adapter state to be initialized. For full integration testing with rooms and broadcasting, consider using the `WsRoomManager` class and setting up adapter state manually. See the [Wooks testing documentation](https://wooks.moost.org/wsapp/testing.html) for advanced patterns.
:::
## Best Practices
- **Use test helpers** rather than manually constructing `EventContext` — they ensure proper context seeding
- **Keep handler logic testable** by extracting business logic into functions that use composables, then test those functions inside mock contexts
- **Test edge cases** with different message data, missing fields, and error conditions
- **Use `parentCtx`** to simulate HTTP-integrated mode when testing composables that traverse the parent context chain
- **Default connection ID** is `'test-conn-id'` — override with the `id` option when testing connection-specific logic