---
URL: "wooks.moost.org/"
LLMS_URL: "wooks.moost.org/index.md"
layout: "home"
title: "Wooks"
titleTemplate: "Home | :title"
themeConfig:
logo: ""
---
## For LLMs
This documentation is available in LLM-friendly format at [llms.txt](https://wooks.moost.org/llms.txt) and [llms-full.txt](https://wooks.moost.org/llms-full.txt).
## AI Agent Skills
Wooks 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 wooksjs/wooksjs
```
Learn more about AI agent skills at [skills.sh](https://skills.sh).
---
URL: "wooks.moost.org/benchmarks/router.html"
LLMS_URL: "wooks.moost.org/benchmarks/router.md"
---
# Router Benchmark
How fast can each router match a URL to a handler and pull out parameters? This benchmark isolates just the routing layer — no HTTP, no middleware, just pure matching.
Full source code: [prostojs/router-benchmark](https://github.com/prostojs/router-benchmark)
## Routers Tested
| Router | Used by | Type |
|---|---|---|
| **[@prostojs/router](https://github.com/prostojs/router)** | Wooks | Regex-based, indexed |
| **find-my-way** | Fastify | Radix trie |
| **rou3** | h3 / Nitro | Radix trie |
| **Hono RegExpRouter** | Hono | Compiled RegExp (falls back to TrieRouter on complex sets) |
| **Express** | Express | Linear scan (baseline) |
## Methodology
Each router registers the same set of routes and resolves the same request mix. Importantly, every benchmark forces **full parameter extraction** — not just matching — so routers that defer param processing don't get an unfair advantage.
The tests cover three route shapes: **short** paths (2–5 segments), **long** paths (5–10 segments), and a **mixed** 50/50 combination. The workload includes statics, parametric routes with up to 4 params, wildcards, multiple HTTP methods, and 404 misses.
Results are in **operations per millisecond** — higher is better.
## Overview by Route Type
::: tip Key takeaway
**ProstoRouter** leads on long, enterprise-style routes while staying competitive on short ones. **Hono's RegExpRouter** is fastest on short and mixed patterns — but as the [scaling section](#scaling) reveals, its compiled regex approach breaks down on larger route sets.
:::
### Short Routes (22 routes)
Typical REST APIs with 2–5 segment paths.
Hono's RegExpRouter and ProstoRouter are neck-and-neck here — both far ahead of the trie-based routers. The compiled regex approach excels on compact route sets.
### Long Routes (22 routes)
The kind of deep, nested paths you'd see in a real SaaS API — 5–10 segments with shared prefixes and multiple parameters. Think `/api/v1/orgs/:orgId/teams/:teamId/projects/:projectId/tasks/:taskId`.
**ProstoRouter leads here** — comfortably ahead of every other router. These are the route patterns that real SaaS APIs actually use, and ProstoRouter handles them best.
### Mixed Routes (20 routes)
50/50 combination of short and long routes.
Hono's RegExpRouter leads here thanks to its strong short-route performance. ProstoRouter and Rou3 are close behind.
## Long Route Breakdown
A closer look at individual long-route test patterns — this is where router architectures reveal their strengths and weaknesses:
**Static lookups:** Rou3 dominates pure static resolution thanks to its radix trie optimized for exact matches. Hono comes second. ProstoRouter's regex-based approach trades some static-lookup speed for richer pattern support — still very fast.
**Parametric routes:** ProstoRouter leads across all parameter counts. The more params a route has, the bigger the gap — ProstoRouter's advantage grows with complexity.
**POST/PUT/DELETE with params:** ProstoRouter and Hono are essentially tied, both ahead of Rou3 and find-my-way.
**404 handling:** Hono's RegExpRouter excels at rejecting non-matching URIs — its compiled regex rejects in one step. The other routers handle 404s reasonably well, with find-my-way slightly ahead of ProstoRouter and Rou3.
## Scaling: 22 → 200 Routes {#scaling}
Small benchmarks can be deceiving. A router that tops the chart with a handful of routes may not hold up when your application grows. This scaling benchmark reveals what happens as route tables reach real-world sizes.
### Short Routes
### Long Routes
### Mixed Routes
::: warning Hono's RegExpRouter hits a wall
Hono's RegExpRouter — the fastest router on small benchmarks — **fails to compile** its regular expression when route sets grow complex. On long routes beyond 100, and mixed routes beyond 198, Hono silently falls back to its much slower TrieRouter — becoming the slowest non-Express router in the field.
:::
This is the most revealing part of the benchmark. The routers that look fastest in small tests tell a different story at scale:
| Router | Scaling behavior |
|---|---|
| **ProstoRouter** | Gradual, predictable slowdown (~2x from 22→200 routes) |
| **Hono** | Hits a cliff — falls back to TrieRouter and drops sharply |
| **Rou3** | Nearly flat — trie structure handles growth well |
| **find-my-way** | Nearly flat — same trie advantage |
**ProstoRouter stays fastest through 50 routes** across all three route shapes, and remains competitive at 100–200. The trie-based routers (Rou3, find-my-way) degrade less because their structure inherently shares prefix storage — but they start from a lower baseline on parametric routes.
At larger scales, the order reshuffles — find-my-way's stable trie pulls ahead on mixed routes, while ProstoRouter still beats Rou3 and Hono's fallback TrieRouter. A router's architecture matters more than its microbenchmark peak.
## Feature Comparison
Raw speed is only part of the picture. `@prostojs/router` supports features that trie-based routers can't:
| Feature | @prostojs/router | find-my-way | Rou3 | Hono |
|---|:---:|:---:|:---:|:---:|
| Parametric routes | ✅ | ✅ | ✅ | ✅ |
| Regex constraints on params | ✅ | ✅ | ❌ | ❌ |
| Multiple wildcards per path | ✅ | ❌ | ❌ | ❌ |
| Regex constraints on wildcards | ✅ | ❌ | ❌ | ❌ |
| Optional parameters | ✅ | ❌ | ❌ | ✅ |
| Case-insensitive matching | ✅ | ✅ | ❌ | ❌ |
| Trailing slash normalization | ✅ | ❌ | ❌ | ❌ |
| URL decoding | ✅ | ✅ | ❌ | ❌ |
| Same-name param arrays | ✅ | ❌ | ❌ | ❌ |
`@prostojs/router` delivers the richest feature set while being the fastest on the route patterns that matter most in production — deeply-nested, parametric paths with multiple parameters.
---
*Benchmark source: [prostojs/router-benchmark](https://github.com/prostojs/router-benchmark). Results generated Feb 2026.*
---
URL: "wooks.moost.org/benchmarks/wooks-http.html"
LLMS_URL: "wooks.moost.org/benchmarks/wooks-http.md"
---
# HTTP Framework Benchmark
How fast is Wooks compared to Express, Fastify, h3, and Hono? This isn't a "hello world" test — it's a production-realistic benchmark that exercises the full HTTP request lifecycle.
Full source code: [prostojs/router-benchmark](https://github.com/prostojs/router-benchmark)
## Why Not "Hello World"?
Most framework benchmarks test a single `GET /` route returning a string. That tells you almost nothing about real-world performance, where every request involves routing, header inspection, cookie parsing, auth checks, body parsing, and response serialization.
Our benchmark simulates a **project management SaaS API** with 21 routes across three authentication tiers — the kind of app you'd actually build.
## Frameworks Tested
| Framework | Router |
|---|---|
| **Wooks** | @prostojs/router |
| **Fastify** | find-my-way |
| **h3** | rou3 |
| **Hono** | RegExpRouter |
| **Express** | Express router (baseline) |
## Methodology
Benchmarks run with [autocannon](https://github.com/mcollina/autocannon) across 20 test scenarios, each in its own child process.
Every request goes through the full HTTP lifecycle — routing, header inspection, cookie parsing (a realistic ~20-cookie jar), auth guards, body parsing, and response serialization.
### Traffic Distribution
Tests are weighted to reflect what a real SaaS app actually sees:
| Traffic type | Weight |
|---|---|
| Cookie-auth reads (browser) | 42% |
| Cookie-auth writes (browser) | 18% |
| Header-auth (API clients) | 16% |
| Public / static | 10% |
| Auth failures | 5% |
| 404 responses | 5% |
| Bot / scanner traffic | 4% |
## Overall Throughput
Weighted average across all 20 test scenarios:
**Wooks comes out on top**, with Fastify close behind, then h3, Hono, and Express trailing by about 1.5x.
::: tip Key insight
While the [router benchmark](/benchmarks/router) showed massive differences between routers, the full framework benchmark compresses the gap to about **1.5x** between fastest and slowest (excluding Express). Routing is a tiny fraction of what an HTTP server does — the rest is TCP, HTTP parsing, headers, cookies, bodies, and serialization.
:::
## Public & Static Routes
Static responses, wildcard routing, login with `set-cookie`.
Wooks and Fastify trade blows on static and wildcard routes, both well ahead of the pack. h3 wins the login/set-cookie scenario — its cookie serialization is notably fast. Express trails behind.
## Header-Auth API Routes
Bearer-token authenticated endpoints — typical API client traffic.
Fastify leads on pure header-auth static routes and auth failure fast-paths. h3 excels at body parsing for small POSTs. Wooks delivers consistently strong results across the board — no weak spots.
## Cookie-Auth Browser Routes
The biggest traffic category in any SaaS app — browser requests with ~20 cookies per request.
**This is where Wooks shines.** On cookie-authenticated parametric routes, Wooks leads decisively — roughly 10–15% faster than Fastify, the next closest competitor.
Why? Wooks parses cookies **lazily**. The `useCookies()` composable doesn't touch the cookie header until you actually call `getCookie()`. Combined with cached parametric parsing from `@prostojs/router`, Wooks avoids work that other frameworks do eagerly.
For large body payloads (~100KB JSON), all frameworks converge to roughly the same speed — body parsing dominates at that scale and erases framework differences.
## Error Responses & Edge Cases
404s, authentication failures with large bodies, bot traffic.
Two notable patterns:
**404 handling:** Fastify and Hono lead the pack. h3 struggles here — its router has higher overhead on failed lookups. Wooks handles 404s comfortably.
**Large body + auth failure:** This tests whether the framework reads the body before checking auth. Wooks and h3 skip body parsing when auth fails — **over 3x faster** than Fastify, which parses the body eagerly regardless. This is the lazy-by-default architecture paying off in a very real scenario.
## Where Wooks Wins
**Cookie-heavy browser traffic.** In the most common SaaS traffic pattern — browser requests with cookies — Wooks leads clearly. Lazy cookie parsing and cached route parameter extraction give it a structural advantage.
**Early rejection of bad requests.** When authentication fails, Wooks skips body parsing entirely. For large payloads with bad credentials, this means dramatically faster rejection than frameworks that parse eagerly.
**Consistent across all scenarios.** Wooks is competitive or leading in the vast majority of test scenarios — the most balanced profile of any framework tested.
## Where Others Shine
**Fastify** leads on pure header-auth static routes and 404 fast-paths. Its mature HTTP pipeline is highly optimized for simple request/response cycles.
**h3** excels at body parsing — both small and large payloads. Its `readBody()` implementation is the fastest in the benchmark. It also wins the login/set-cookie scenario.
**Hono** is fast on static routes and 404 handling, though its cookie-auth performance falls behind.
## The Real-World Perspective
These benchmarks measure raw framework throughput — the theoretical ceiling. In a real application, your handlers make database queries, call external APIs, and process business logic. Those operations take orders of magnitude longer than framework overhead.
When we added simulated Redis calls to this same benchmark, the gap between all frameworks shrank significantly:
The top four frameworks cluster tightly together. With real database queries and data processing, the differences become **negligible from a performance perspective**.
::: info The bottom line
Performance alone shouldn't drive your framework choice. All five frameworks tested here are fast enough for any production workload. Pick the one that gives you the best abstractions, developer experience, and ecosystem for your problem.
:::
## Why Wooks Is the Right Choice
Wooks doesn't just win on throughput — it wins on **what you get for that throughput**.
**Speed without sacrificing DX.** Wooks gives you top throughput *and* the cleanest API. No `(req, res)` threading, no middleware ordering puzzles. Just composable functions — `useRequest()`, `useBody()`, `useCookies()` — that work anywhere in your call stack.
**Lazy by default means fast by default.** Cookies aren't parsed until you read one. Bodies aren't deserialized until you ask. Route params aren't extracted until accessed. The benchmarks prove this architecture pays off where it matters most: real-world traffic with cookies and complex routes.
**Type-safe from the ground up.** Every composable returns typed data. No `req.user as User` casts, no runtime type assertions. TypeScript works *with* you, not against you.
**One pattern beyond HTTP.** The same composable pattern powers [CLI apps](/cliapp/), [WebSocket servers](/wsapp/), and [workflow engines](/wf/). Learn one abstraction, apply it everywhere. No other framework in this benchmark offers that.
**Built-in context management.** `AsyncLocalStorage`-backed event context with `cached()` slots, typed storage, and composable factories. Every composable you write gets per-request caching, type safety, and zero-argument access for free.
**The richest router in the benchmark.** `@prostojs/router` supports regex constraints, multiple wildcards, optional parameters, case-insensitive matching, and trailing slash normalization — features the competition simply doesn't have. And it's still the [fastest on the routes real apps use](/benchmarks/router).
---
*Benchmark source: [prostojs/router-benchmark](https://github.com/prostojs/router-benchmark). Results generated Feb 2026.*
---
URL: "wooks.moost.org/cliapp"
LLMS_URL: "wooks.moost.org/cliapp.md"
---
# Quick Start Guide
## Installation
```bash
npm install @wooksjs/event-cli
```
## Usage
Here's a step-by-step guide to using Wooks CLI:
### Step 1: Import `createCliApp` factory and create an App instance
Start by importing the necessary modules and creating an instance of the Wooks CLI adapter:
::: code-group
```ts [plain]
import { createCliApp } from '@wooksjs/event-cli'
import { useRouteParams } from '@wooksjs/event-cli'
const app = createCliApp()
```
```ts [with auto-help]
import {
createCliApp,
useAutoHelp,
useCommandLookupHelp,
} from '@wooksjs/event-cli'
import { useRouteParams } from '@wooksjs/event-cli'
const app = createCliApp({
// Implementing onUnknownCommand hook
onUnknownCommand: (path, raiseError) => {
// Whenever cli command was not recognized by router
// this callback will be called
let printed = false
try {
// prints help when --help is provided; throws when --help
// is set but the entered command has no matching help entry
printed = !!useAutoHelp()
} catch {
// no help entry for this input
}
if (!printed) {
// suggest similar commands if possible
useCommandLookupHelp()
// fallback to a standard error handling when command not recognized
raiseError()
}
},
})
```
:::
### Step 2: Define CLI commands
Next, you can define your CLI commands using the cli() method provided by the Wooks CLI adapter.
The cli() method allows you to register CLI commands along with their respective handlers.
::: code-group
```ts [plain]
app.cli('command/:arg', () => {
// Handle the command and its parameters
return `Command executed with argument: ${useRouteParams().get('arg')}`
});
```
```ts [with auto-help]
app.cli('command/:arg', () => {
useAutoHelp() && process.exit(0) // Print help if --help option provided
// Handle the command and its parameters
return `Command executed with argument: ${useRouteParams().get('arg')}`
});
```
:::
### Step 3: Start command processing
To start processing CLI commands, you can call the `run()` method of the Wooks CLI adapter.
By default, it uses `process.argv.slice(2)` as the command, but you can also pass your own argv array as an argument.
```ts
app.run()
```
### Step 4: Execute CLI commands
You can now execute your registered CLI commands by running your script with the appropriate command and arguments.
Here's an example:
```bash
node your-script.js command test
```
This will execute the registered CLI command with the argument "test" and log the result to the console.
## Configuration
`createCliApp()` accepts an options object:
- `onError` — called when a handler throws or returns an `Error`. By default the message is printed to `stderr` and the process exits with code `1`.
- `onNotFound` — a regular handler invoked when no command matches; its return value is printed like any handler result. When provided, it takes precedence over `onUnknownCommand`.
- `onUnknownCommand` — callback for unrecognized commands. It receives the entered command segments and a `raiseError()` function that prints the standard "Unknown command" error and exits with code `1` (that is also the default behavior when neither `onNotFound` nor `onUnknownCommand` is set).
- `router` — options for the underlying [@prostojs/router](https://github.com/prostojs/router).
- `cliHelp` — an existing `CliHelpRenderer` instance or options for a new one. Use `cliHelp: { name: 'my-cli' }` to set the CLI name shown in generated help output (defaults to the script filename).
- `logger` — custom logger, see [Logging in Wooks](/cliapp/logging).
- `eventOptions` — `EventContextOptions` applied to each event context (e.g. a `parent` context); see [Wooks Context](/wooks/advanced/wooks-context).
## Advanced Usage
Wooks CLI provides additional features and options for building more complex CLIs. Some of the notable features include:
- Defining command [aliases](/cliapp/cli-help#aliases)
- Adding [descriptions](/cliapp/cli-help#command-description), [options](/cliapp/cli-help#options), and [examples](/cliapp/cli-help#examples) to commands
- Handling unknown commands
- Error handling and customization
## What Each Page Covers
- [Introduction](/cliapp/introduction) — what `@wooksjs/event-cli` is and its key components
- [Routing](/cliapp/routing) — command patterns, arguments, and optional parameters
- [Command Options](/cliapp/options) — declaring options and reading parsed flags
- [Command Usage (Help)](/cliapp/cli-help) — command metadata and auto-generated `--help` output
- [Logging in Wooks](/cliapp/logging) — configuring the logger for your CLI app
## AI Agent Skills
Wooks 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 wooksjs/wooksjs
```
Learn more about AI agent skills at [skills.sh](https://skills.sh).
---
URL: "wooks.moost.org/cliapp/cli-help.html"
LLMS_URL: "wooks.moost.org/cliapp/cli-help.md"
---
# Command Usage (Help)
Auto-generated help output powered by [@prostojs/cli-help](https://github.com/prostojs/cli-help). Define metadata when registering commands — descriptions, options, args, aliases, examples — and help is generated automatically.
## Usage
Define command metadata when calling `app.cli()`:
```js
app.cli('my-command/:arg', {
description: 'Description of the command',
options: [
{ keys: ['project', 'p'], description: 'Description of the option', value: 'myProject' },
],
args: { arg: 'Description of the arg' },
aliases: ['cmd'],
examples: [
{
description: 'Example of usage with someProject',
cmd: 'argValue -p=someProject',
},
],
handler: () => '...',
});
```
In the above example, we define a command named `my-command/:arg` with its description, options, arguments, aliases, examples, and a command handler.
### Command Description
The `description` property allows you to provide a description for your command. It should give users an understanding of what the command does.
Example:
```js
app.cli('my-command', {
description: 'Description of the command', // [!code focus]
handler: () => '...',
});
```
### Options
The `options` property is an array that defines the available options for your command. Each option is represented by an object with the following properties:
- `keys`: An array of option keys that describe synonyms for the option. For example, `['project', 'p']` stands for `--project=...` input, and it has a shortcut `-p=...` that can be used as an alternative option.
- `description`: (Optional) The description of the option, which explains its purpose.
- `value`: (Optional) An example of the value that will be represented in the CLI command usage.
Example:
```js
app.cli('my-command', {
options: [{ // [!code focus]
keys: ['project', 'p'], // [!code focus]
description: 'Description of the option', // [!code focus]
value: 'myProject' // [!code focus]
}], // [!code focus]
handler: () => '...',
});
```
### Arguments
The args property is an object where each key represents the name of an argument, and the corresponding value is the description of the argument.
It helps users understand the purpose of each argument in the command.
Example:
```js
app.cli('my-command/:name', {
args: { name: 'Description of the argument name' }, // [!code focus]
handler: () => '...',
});
```
### Aliases
The aliases property is an array that allows you to specify aliases for your command.
Aliases provide alternative names for the command, making it more flexible for users.
Example:
```js
app.cli('my-command', {
aliases: ['cmd'], // [!code focus]
handler: () => '...',
});
```
### Examples
The examples property is an array that contains examples demonstrating the usage of your command.
Each example is represented by an object with the following properties:
- `description`: A description explaining the purpose of the example.
- `cmd`: The command string that represents the example. This command will be displayed in the help output.
Example:
```js
app.cli('my-command', {
examples: [{ // [!code focus]
description: 'Example of usage with someProject', // [!code focus]
cmd: 'argValue -p=someProject', // [!code focus]
}], // [!code focus]
handler: () => '...',
});
```
### Command Handler
The handler property represents the function that will be executed when the command is invoked.
This function can contain the logic for handling the command and returning the desired result.
Example:
```js
app.cli('my-command', {
handler: () => { // [!code focus]
return 'my-command executed' // [!code focus]
}, // [!code focus]
});
```
## Automatic Help Display
To enable automatic help display when the `--help` option is used, you can use the `useAutoHelp` composable function within your command's handler. Here's an example:
```js
import { useAutoHelp, useCliOption } from '@wooksjs/event-cli'
app.cli('root/:arg', {
args: { arg: 'First argument' },
description: 'Root Command Descr',
options: [
{ keys: ['project', 'p'], description: 'Project name', value: 'test' },
],
handler: () => {
if (useAutoHelp()) { // [!code ++]
process.exit(0); // Stop the command if help is displayed // [!code ++]
} // [!code ++]
// Proceed with handling the command
return 'done ' + useCliOption('project');
},
});
```
When running this command with option `--help` (the required `arg` must be present so the route matches) you'll see the usage instructions:
```bash
node your-script.js root someArg --help
```
`useAutoHelp` only runs inside a matched handler. If the command is unrecognized (e.g., missing required args), the handler never runs. Use `onUnknownCommand` to cover that case:
```js
import { createCliApp, useAutoHelp, useCommandLookupHelp } from '@wooksjs/event-cli'
// Create a CLI app with onUnknownCommand hook
const app = createCliApp({
// This callback is triggered when the CLI command is not recognized by the router
onUnknownCommand: (path, raiseError) => {
let printed = false
try {
// prints help when --help is provided; throws when --help
// is set but the entered command has no matching help entry
printed = !!useAutoHelp()
} catch {
// no help entry for this input
}
if (!printed) {
// Display command lookup help if command help was not found
useCommandLookupHelp()
// Raise a standard error when the command is not recognized
raiseError()
}
},
});
```
Inside `onUnknownCommand`, `useAutoHelp()` throws when `--help` was passed but the entered command has no matching help entry (e.g., a completely unknown command) — hence the `try/catch`. Without `--help` it returns `undefined` and never throws.
If `--help` isn't present, `useCommandLookupHelp()` suggests similar commands. If nothing matches, `raiseError()` prints the standard "Unknown command: ..." error and exits the process with code 1.
## Rendering Help Manually
Use the `useCliHelp()` composable to render or print help programmatically, outside of the `--help` auto path:
```js
import { useCliHelp } from '@wooksjs/event-cli'
app.cli('my-command', {
description: 'Description of the command',
handler: () => {
const { render, print } = useCliHelp()
print(true) // print help to the console (with colors)
const lines = render(80, false) // help as an array of strings (width 80, no colors)
// ...
},
});
```
`useCliHelp()` returns:
- `getCliHelp()` — the underlying [@prostojs/cli-help](https://github.com/prostojs/cli-help) renderer instance
- `getEntry()` — the help entry matched for the current command
- `render(width?, withColors?)` — renders the help for the current command as an array of strings
- `print(withColors?)` — prints the help for the current command to the console
::: warning
`getEntry()`, `render()`, and `print()` throw when no help entry matches the current command (e.g., inside `onUnknownCommand` for a completely unknown command).
:::
---
URL: "wooks.moost.org/cliapp/introduction.html"
LLMS_URL: "wooks.moost.org/cliapp/introduction.md"
---
# Introduction to Wooks CLI
`@wooksjs/event-cli` is the CLI adapter for Wooks. It lets you build command-line applications using the same composable architecture as Wooks HTTP — routed commands, typed options, auto-generated help, and per-invocation context via `AsyncLocalStorage`.
## Key Components
- **`createCliApp()`** — Factory that creates a CLI application with routing, option parsing, and help generation
- **`app.cli(pattern, handler)`** — Register commands with route-style patterns (e.g., `deploy/:env`)
- **Composables** — `useCliOptions()`, `useCliOption()`, `useAutoHelp()`, `useRouteParams()` for accessing parsed CLI data
- **Help Renderer** — Powered by [@prostojs/cli-help](https://github.com/prostojs/cli-help), auto-generates `--help` output from command metadata
---
URL: "wooks.moost.org/cliapp/logging.html"
LLMS_URL: "wooks.moost.org/cliapp/logging.md"
---
---
URL: "wooks.moost.org/cliapp/options.html"
LLMS_URL: "wooks.moost.org/cliapp/options.md"
---
# Command Options
Wooks CLI supports handling options in your CLI commands.
Options are typically defined with a double hyphen (`--`) or a single hyphen (`-`) prefix and can have an associated value.
To define options in Wooks CLI, you can use the options property when registering your command. Here's an example:
```js
import { useCliOption } from '@wooksjs/event-cli'
app.cli('my-command', {
options: [
// Define the "--project" option with a shortcut as "-p"
{ keys: ['project', 'p'] },
],
handler: () => {
const project = useCliOption('project');
return 'my command option project = ' + project;
},
});
```
With the above command configuration, you can execute the command as follows:
```bash
node your-script.js my-command --project=test
```
Alternatively, you can use the shortcut for `project` option:
```bash
node your-script.js my-command -p=test
```
This will trigger the CLI command with the `project` option set to `"test"` and log the result to the console.
## Reading All Options
`useCliOptions()` returns the full parsed-flags object, including the positional arguments in `_`:
```js
import { useCliOptions } from '@wooksjs/event-cli'
app.cli('my-command/:arg?', {
handler: () => {
const flags = useCliOptions()
// node your-script.js my-command extra --project=test -v
// flags = { _: ['my-command', 'extra'], project: 'test', v: true }
return JSON.stringify(flags)
},
});
```
## Parsing Behavior
Options are parsed with [minimist](https://www.npmjs.com/package/minimist). To customize parsing (`string`, `boolean`, `alias`, `default`, ...), pass minimist options as the second argument of `run()`:
```js
app.run(undefined, { boolean: ['verbose'], alias: { v: 'verbose' } })
```
## Gotchas
- `useCliOption('project')` resolves synonyms (e.g., `-p` for `--project`) only when the option is declared in the command's `options` metadata (see [Command Usage (Help) — Options](/cliapp/cli-help#options)). For undeclared options it looks up the raw flag name only.
---
URL: "wooks.moost.org/cliapp/routing.html"
LLMS_URL: "wooks.moost.org/cliapp/routing.md"
---
# Routing
Wooks CLI provides a powerful routing system that allows you to define and handle command-line interface (CLI) commands with ease.
This documentation will guide you through the process of defining routes, handling arguments, and working with options in Wooks CLI.
::: info
Wooks utilizes [@prostojs/router](https://github.com/prostojs/router) for routing, and its
documentation is partially included here for easy reference.
:::
## Routing Basics
In Wooks CLI, routing is the process of mapping CLI commands to their respective handlers.
A route consists of a command pattern.
The command pattern defines the structure of the command, including the command name and arguments.
## Command Structure
Wooks CLI represents commands as paths due to the underlying router used.
For example, to define the command `npm install @wooksjs/event-cli`, you can use the following command pattern:
```js
'/install/:package'
```
In the above pattern, `:package` represents a variable. Alternatively, you can use a _space_ as a separator, like this:
```js
'install :package'
```
Both command patterns serve the same purpose.
If you need to use a colon in your command, it must be escaped with a backslash (\\). For example:
```js
'app build\\:dev'
```
The above command pattern allows the command to be executed as follows:
```bash
my-cli app build:dev
```
### Optional Parameters
You can define optional route parameter
```js
'app build :target?'
```
In the example above parameter `target` will be optional.
```bash
my-cli app build
```
```bash
my-cli app build my-target
```
Both commands will be handled by `app build :target?` pattern. In the first scenario parameter `target` will be undefined. In the second scenario parameter `target` will get `my-target` value.
## Gotchas
- Commands are matched as router paths: each positional argument becomes a path segment. A `/` inside an argument value does not break the match — `run()` encodes it as `%2F`, so the value still binds to a single `:param` and the handler receives the original value.
## Next Steps
- [Command Options](/cliapp/options) — declare and read `--options`
- [Command Usage (Help)](/cliapp/cli-help) — add descriptions, args, aliases, and examples to commands
---
URL: "wooks.moost.org/team.html"
LLMS_URL: "wooks.moost.org/team.md"
layout: "page"
title: "Meet the Team"
description: "The development of Wooks is guided by a single software engineer."
---
Meet the Team
The development of Wooks is driven by a single software developer.
---
URL: "wooks.moost.org/webapp"
LLMS_URL: "wooks.moost.org/webapp.md"
---
# Get Started with Web App
::: info
Learn more about Wooks to understand its philosophy and advantages:
- [What is Wooks?](/wooks/what)
- [Why Wooks?](/wooks/why)
- [Comparison with Express, Fastify, and h3](/webapp/comparison)
:::
Or you can get hands-on with the HTTP flavor of Wooks right now.
## Installation
```bash
npm install @wooksjs/event-http
```
This gives you access to the Wooks core library and the HTTP adapter, which provides a simple, Express-like API for creating HTTP servers while leveraging Wooks’ composable and context-driven patterns.
## Creating Your First "Hello World" App
In this example, we’ll create an HTTP server that responds to `GET /hello/:name` with a personalized greeting.
**Example:**
```js
import { createHttpApp, useRouteParams } from '@wooksjs/event-http'
const app = createHttpApp()
// Register a route handler using the GET method shortcut:
app.get('hello/:name', () => `Hello ${useRouteParams().get('name')}!`)
// Start the server on port 3000
app.listen(3000, () => {
// Use the built-in logger to print a startup message
app.getLogger('[App]').log('Wooks Server is up on port 3000')
})
```
**Test It:**
```bash
curl http://localhost:3000/hello/World
# Hello World!
```
## Using Node’s `http` Server Directly
You can create http(s) server manually and pass the server callback from the Wooks HTTP app.
Use `getServerCb()` to plug Wooks into an `http` server of your own.
**Example:**
```js
import { createHttpApp, useRouteParams } from '@wooksjs/event-http'
import http from 'http' // [!code ++]
const app = createHttpApp()
app.get('hello/:name', () => `Hello ${useRouteParams().get('name')}!`)
const server = http.createServer(app.getServerCb()) // [!code ++]
server.listen(3000, () => { // [!code ++]
app.listen(3000, () => { // [!code --]
console.log('Wooks Server is up on port 3000')
})
```
**Test It:**
```bash
curl http://localhost:3000/hello/Wooks
# Hello Wooks!
```
When you create the server yourself, call `app.attachServer(server)` so that `app.close()` can stop it. `app.getServer()` returns the attached (or `listen()`-created) `http.Server`, and `await app.close()` shuts it down gracefully.
## AI Agent Skills
Wooks 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 wooksjs/wooksjs
```
Learn more about AI agent skills at [skills.sh](https://skills.sh).
## What Each Page Covers
- [Introduction](/webapp/introduction) — What `@wooksjs/event-http` gives you at a glance.
- [Comparison](/webapp/comparison) — Concrete differences vs Express, Fastify, and h3.
- [Routing](/webapp/routing) — Route registration, parametric routes, wildcards, path builders.
- [Request Composables](/webapp/composables/request) — Headers, query params, cookies, authorization, client IP, body limits.
- [Response Composables](/webapp/composables/response) — Status, headers, cookies, cache control, error responses.
- [Body Parser](/webapp/body) — Parsing JSON, form data, and more with `@wooksjs/http-body`.
- [Proxy Requests](/webapp/proxy) — Reverse proxying with `@wooksjs/http-proxy`.
- [Serve Static](/webapp/static) — Static file serving with `@wooksjs/http-static`.
- [Programmatic Fetch](/webapp/fetch) — Call your routes in-process with `app.fetch()` / `app.request()`.
- [Testing](/webapp/testing) — Integration tests via `app.request()` and unit tests via `prepareTestHttpContext()`.
- [Context and Hooks](/webapp/more-hooks) — Build your own composables with `defineWook`.
- [Framework Integrations](/webapp/integrations/) — Run Wooks inside Express, Fastify, or h3.
- [Logging](/webapp/logging) — Event loggers and logger configuration.
Once comfortable with HTTP, consider exploring the CLI or Workflow flavors for broader event-driven architectures.
---
URL: "wooks.moost.org/webapp/body.html"
LLMS_URL: "wooks.moost.org/webapp/body.md"
---
# Body Parser
`@wooksjs/http-body` parses the request body based on `Content-Type` — on demand, cached per request.
Supported content types: `application/json`, `text/*`, `multipart/form-data`, `application/x-www-form-urlencoded`.
Nothing is parsed until you call `parseBody()`.
## Installation
```bash
npm install @wooksjs/http-body
```
## Usage
Once installed, you can import and use the `useBody` composable function in your Wooks application.
Example:
```js
import { useBody } from '@wooksjs/http-body'
app.post('test', async () => {
const { parseBody } = useBody()
const data = await parseBody()
})
```
The `useBody` function provides an `is(type)` checker and access to the raw body buffer.
Example:
```js
import { useBody } from '@wooksjs/http-body';
app.post('test', async () => {
const {
is, // checks the content type : (type) => boolean
parseBody, // parses the body according to the content type : () => Promise;
rawBody, // returns the raw body buffer : () => Promise;
} = useBody();
// Short names: 'json', 'html', 'xml', 'text', 'binary', 'form-data', 'urlencoded'
// Or full MIME types: 'application/msgpack', 'image/png', etc.
if (is('json')) {
console.log(await parseBody());
}
});
```
## Gotchas
- **Malformed JSON and prototype-pollution keys throw `HttpError(400)`.** JSON bodies containing `__proto__`, `constructor`, or `prototype` keys are rejected; the same keys are rejected as urlencoded and form-data field names.
- **Parsed form-data/urlencoded objects have a null prototype** — no `hasOwnProperty` or other `Object.prototype` methods.
- **The built-in `multipart/form-data` parser handles text fields only.** Values are parsed line-by-line and trimmed, so binary file uploads are not preserved byte-for-byte — use `rawBody()` with a dedicated multipart library for file uploads. Limits (not configurable): 255 fields, 100-character field names, 100 KB per field value — exceeding them throws `HttpError(413)`.
- **Unrecognized or missing `Content-Type` returns the body as a plain string** — no error is thrown.
- **Body size and read-time limits apply** to `rawBody()`/`parseBody()` — see [Body Size Limits](/webapp/composables/request#body-size-limits).
## Custom Body Parser
Use `rawBody` to access the raw body buffer for custom parsing:
```js
import { useBody } from '@wooksjs/http-body';
app.post('test', async () => {
const { rawBody } = useBody();
const bodyBuffer = await rawBody();
// Custom parsing logic for bodyBuffer...
});
```
For reusable parsing logic, create a custom composable with `defineWook` and `cached`:
::: code-group
```ts [custom-parser-composable.ts]
import { useBody } from '@wooksjs/http-body';
import { useHeaders } from '@wooksjs/event-http';
import { defineWook, cached } from '@wooksjs/event-core';
export const useCustomBody = defineWook((ctx) => {
// Using the `rawBody` composable to get the raw body buffer
const { rawBody } = useBody(ctx);
// Preparing default body parser for fallbacks
const defaultParser = useBody(ctx).parseBody;
// Getting the content-type
const { 'content-type': contentType } = useHeaders(ctx);
// A cached slot ensures parsing happens only once per request
const parsedSlot = cached(async (ctx) => {
// Do custom parsing only for 'my-custom-content'
if (contentType === 'my-custom-content') {
const bodyBuffer = await rawBody();
const parsedBody = '...'
// Your custom parsing logic for bodyBuffer...
return parsedBody
} else {
// Fallback to default parser
return defaultParser();
}
});
return {
parseBody: () => ctx.get(parsedSlot),
rawBody,
};
});
```
```ts [index.ts]
import { useCustomBody } from './custom-parser-composable';
app.post('test', async () => {
const { parseBody } = useCustomBody();
console.log(await parseBody());
});
```
:::
`defineWook` runs the factory once per request. The `cached` slot ensures parsing happens only once even if `parseBody` is called multiple times. See [Custom Composables](/webapp/more-hooks) for more examples.
---
URL: "wooks.moost.org/webapp/comparison.html"
LLMS_URL: "wooks.moost.org/webapp/comparison.md"
---
# Comparison with Other HTTP Frameworks
A concrete look at how Wooks HTTP differs from Express, Fastify, and h3. Wooks and h3 share similar philosophy — on-demand parsing, return-value responses. The core difference is the path each took: h3 threads an `event` object; Wooks uses `AsyncLocalStorage` and provides context primitives that make lazy computation with caching the default by design.
## Request Lifecycle
**Express / Fastify:** Middleware runs before your handler. Body parsing, cookie parsing, auth checks — they execute on every request that matches their mount path, whether the handler needs the result or not. By the time your route function runs, work has already been done.
**h3:** Philosophically close to Wooks — on-demand parsing, return values as responses. `readBody(event)` reads the stream when you call it, not before. The difference is in the wiring: h3 threads an `event` object through every call. (h3 v2 added a `next()`-based middleware system, but the on-demand philosophy remains.)
**Wooks:** Same on-demand philosophy, different mechanism. Context lives in `AsyncLocalStorage` — composables need no arguments. And the context primitives (`cached`, `cachedBy`, `defineWook`) make lazy-with-caching the default: `useBody().parseBody()` parses once, caches for the event lifetime, returns the cached result on repeat calls — all by design, not by manual memoization.
```ts
// Express: body is already parsed by the time you get here
app.post('/users', (req, res) => {
if (!req.headers.authorization) return res.status(401).end()
// req.body was parsed anyway
})
// Wooks: nothing happens until you ask
app.post('/users', () => {
const { authorization } = useHeaders()
if (!authorization) throw new HttpError(401)
// body is never touched
const { parseBody } = useBody()
return parseBody() // parsed only now
})
```
## Routing
All four frameworks support parametric routes. The differences are in edge cases and performance.
**Express:** Linear scan — checks routes in registration order. No indexing, no caching. Slows down with large route tables.
**Fastify (find-my-way):** Radix-tree based. Fast for static routes, handles parameters well. Some quirks with URI encoding/decoding in edge cases.
**h3 (rou3):** Fast for static lookups. Weaker on complex dynamic patterns — regex constraints on parameters and wildcards are not supported.
**Wooks ([@prostojs/router](https://github.com/prostojs/router)):** Categorizes routes into statics, parameters, and wildcards with indexing and caching. Notable capabilities:
- Multiple wildcards in one path: `/static/*/assets/*`
- Regex constraints on parameters: `/api/time/:hours(\\d{2})h:minutes(\\d{2})m`
- Regex constraints on wildcards: `/static/*(\\d+)`
- On-the-fly generated parsers — parameter extraction in a single function call
### Router Performance
In a [pure router benchmark](https://github.com/prostojs/router-benchmark) (ops/ms, higher is better):
| Route type | @prostojs/router | Hono RegExpRouter | rou3 | find-my-way | Express |
|---|---:|---:|---:|---:|---:|
| Short routes | 36,385 | **37,863** | 32,242 | 22,374 | 2,348 |
| **Long routes** | **9,020** | 8,250 | 7,157 | 6,283 | 1,141 |
| Mixed routes | 21,432 | **37,617** | 20,828 | 16,786 | 2,201 |
`@prostojs/router` leads on long, parametric enterprise routes — the patterns real SaaS APIs actually use. On short routes, Hono's RegExpRouter edges ahead — but its regex-compiled approach [fails to scale beyond ~50–100 complex routes](/benchmarks/router#scaling), silently falling back to a much slower TrieRouter.
### HTTP Framework Throughput
In a [full HTTP lifecycle benchmark](https://github.com/prostojs/router-benchmark) testing 21 routes with authentication, cookies, and body parsing:
| Framework | Avg req/s | Relative |
|---|---:|---|
| **Wooks** | **70,332** | **fastest** |
| Fastify | 68,273 | 1.03x slower |
| h3 | 64,860 | 1.08x slower |
| Hono | 59,466 | 1.18x slower |
| Express | 47,147 | 1.49x slower |
Wooks dominates cookie-heavy browser traffic (the most common SaaS pattern) thanks to lazy cookie parsing and cached route parameter extraction. On auth-failure scenarios with large bodies, Wooks skips body parsing entirely — **3.5x faster** than frameworks that parse eagerly.
See the full benchmark analysis with charts: [Router benchmarks](/benchmarks/router) and [HTTP framework benchmarks](/benchmarks/wooks-http).
## Context Passing
**Express / Fastify:** Attach data to `req`. Custom properties (`req.user`, `req.parsedBody`) have no type definitions unless you extend the interface. Every function that needs context receives `(req, res)`.
**h3:** Replaces `req`/`res` with a single `event` object — cleaner, but still threaded explicitly: `readBody(event)`, `getQuery(event)`, `getCookie(event)`. Every utility takes `event` as its first argument, which means every helper function you extract must accept and forward it.
**Wooks:** `AsyncLocalStorage` eliminates the threading entirely. Composables take no arguments — they resolve context from the current async scope:
```ts
// h3
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const query = getQuery(event)
const cookie = getCookie(event, 'session')
})
// Wooks
app.post('/endpoint', async () => {
const body = await useBody().parseBody()
const query = useUrlParams()
const cookie = useCookies().getCookie('session')
})
```
No `event` threading. Composables work at any depth in the call stack — inside utility functions, inside library code, inside `async` helpers — without passing anything.
## Response Control
**Express:** `res.status(200).json(data)` — imperative calls on the response object, which you must receive as a parameter.
**Fastify:** `reply.code(200).send(data)` — similar pattern, similar coupling.
**h3:** Return values become the response — same idea as Wooks. `setResponseStatus(event, 200)` for explicit control. Still requires `event` threading.
**Wooks:** Return values become the response too. For explicit control, `useResponse()` returns an `HttpResponse` with chainable methods — no arguments needed:
```ts
app.get('/data', () => {
useResponse()
.setStatus(200)
.setHeader('x-custom', 'value')
.setCookie('session', 'abc', { httpOnly: true })
.setCacheControl({ public: true, maxAge: 3600 })
return { data: 'hello' }
})
```
All response methods live on one chainable object — `useResponse()` is the single entry point for status, headers, cookies, and cache control.
## Summary
| | Express | Fastify | h3 | Wooks |
|---|---------|---------|-----|-------|
| Body parsing | Middleware (eager) | Built-in (eager) | `readBody(event)` (on demand) | `useBody().parseBody()` (on demand, cached) |
| Context passing | `req` / `res` params | `request` / `reply` params | `event` param | Implicit (AsyncLocalStorage) |
| Routing | Linear scan | Radix tree | Radix tree | Indexed + cached, regex params, multi-wildcard |
| Response API | `res.status().json()` | `reply.code().send()` | Return value + `setResponseStatus(event)` | Return value + `useResponse()` chainable |
| TypeScript | Bolted on | Schema-driven | Good | Native, composable-level inference |
| Beyond HTTP | No | No | WebSocket (crossws), SSE | CLI, WebSocket, Workflows, custom events |
---
URL: "wooks.moost.org/webapp/composables"
LLMS_URL: "wooks.moost.org/webapp/composables.md"
---
# Composables
A composable function, also known as a hook, is a function that connects you to the event context, which includes URL parameters, request body, cookies, and more.
*Read more about [What is a Wook](/wooks/what#what-is-a-wook)*
Wooks HTTP provides various useful composable functions that can be categorized into the following groups:
- [Request Composables](./request.md): Functions related to the request, such as headers, cookies, and the request body.
- [Response Composables](./response.md): Functions for setting the response, including headers, cookies, status, and cache control.
- [Body Parser](../body.md): Parsing the request body (JSON, form data, urlencoded, and more) via `@wooksjs/http-body`.
- [Proxy Requests](../proxy.md): Proxying requests to other servers via `@wooksjs/http-proxy`.
- [Serve Static](../static.md): Serving static files via `@wooksjs/http-static`.
You can also create your own composables to encapsulate additional logic, such as retrieving user data based on cookies or authentication headers.
---
URL: "wooks.moost.org/webapp/composables/request.html"
LLMS_URL: "wooks.moost.org/webapp/composables/request.md"
---
# Request Composables
Composables for reading incoming HTTP request data: headers, query parameters, cookies, authorization, client IP, body size limits.
[[toc]]
## Raw Request Instance
To get a reference to the raw request instance, you can use the `useRequest` composable function.
However, in most cases, you won't need to directly access the raw request instance unless
you're developing a new feature or require low-level control over the request.
```js
import { useRequest } from '@wooksjs/event-http'
app.get('/test', () => {
const { raw } = useRequest()
// Access the raw request instance if needed
})
```
## URI Parameters
URI parameters are automatically parsed by the router
and are covered in the [Retrieving URI Parameters section](../routing.md#retrieving-uri-params).
## Query Parameters
The `useUrlParams` composable provides three functions for working with query parameters:
- `params()` — returns an instance of `WooksURLSearchParams`, which extends the standard `URLSearchParams` with a `toJson` method that returns a **JSON** object of the query parameters.
- `toJson()` — is a shortcut for `params().toJson()`, returning the query parameters as a **JSON** object.
- `raw()` — returns the raw search parameter string, such as `?param1=value&...`.
```js
import { useUrlParams } from '@wooksjs/event-http'
app.get('hello', () => {
const { params, toJson, raw } =
useUrlParams()
// curl http://localhost:3000/hello?name=World
console.log(toJson()) // { name: 'World' }
console.log(raw()) // ?name=World
return `Hello ${params().get('name')}!`
})
```
Example usage with cURL:
```bash
curl http://localhost:3000/hello?name=World
# Hello World!
```
### Arrays and safe `toJson()` conversion
`params()` returns a `WooksURLSearchParams` — a subclass of the standard `URLSearchParams` whose only addition is a typed `toJson()` method. The standard accessors (`get`, `getAll`, `has`, `entries`, …) work exactly as in Node's `URLSearchParams`.
`toJson()` follows a few rules that protect against malformed or hostile query strings:
- **Arrays use a trailing `[]`.** A key ending in `[]` collects all its values into a `string[]`, and the `[]` is kept in the resulting key. So `?tags[]=a&tags[]=b` becomes `{ 'tags[]': ['a', 'b'] }`.
- **Repeated plain keys throw.** A non-`[]` key that appears more than once raises `HttpError(400)` (`Duplicate key`). Use the `[]` form when you expect multiple values; if you need the raw repeated values without the array convention, read them with `params().getAll('key')` instead of `toJson()`.
`toJson()` also rejects the prototype-pollution keys `__proto__`, `constructor`, and `prototype` with `HttpError(400)`, and builds a null-prototype object.
```js
// ?status=open&tags[]=urgent&tags[]=api
const query = useUrlParams().toJson()
// { status: 'open', 'tags[]': ['urgent', 'api'] }
```
## Method and Headers
The `useRequest` composable provides additional shortcuts for accessing useful data related to the request, such as the URL, method, headers, and the raw request body.
```js
import { useRequest } from '@wooksjs/event-http'
app.get('/test', async () => {
const {
url, // Request URL (string)
method, // Request method (string)
headers, // Request headers (object)
rawBody, // Request body (() => Promise)
reqId, // Per-event UUID (() => string)
isCompressed, // Whether the body is gzip/deflate/br compressed (() => boolean)
} = useRequest()
const body = await rawBody() // Body as a Buffer
})
```
`reqId()` returns a stable per-event UUID — the same value as `useEventId().getId()` from `@wooksjs/event-core`.
`useHeaders()` is a shortcut that returns the request headers record directly (`IncomingHttpHeaders`, lowercased names) — equivalent to `useRequest().headers`:
```js
import { useHeaders } from '@wooksjs/event-http'
app.get('/test', () => {
const { 'content-type': contentType } = useHeaders()
})
```
## Client IP
`useRequest()` provides two helpers for resolving the client's IP address:
```js
import { useRequest } from '@wooksjs/event-http'
app.get('/test', () => {
const { getIp, getIpList } = useRequest()
getIp() // Socket remote address
getIp({ trustProxy: true }) // First `x-forwarded-for` entry, falling back to the socket address
getIpList()
// {
// remoteIp: '...', // socket remote address
// forwarded: ['...'], // all `x-forwarded-for` entries
// }
})
```
By default `getIp()` returns the socket's remote address and ignores `x-forwarded-for`, which any client can spoof. Pass `{ trustProxy: true }` only when your app runs behind a trusted reverse proxy that sets that header.
## Cookies
Cookies are not automatically parsed unless requested. The `useCookies` composable function provides a cookie getter and access to the raw cookies string.
```js
import { useCookies } from '@wooksjs/event-http'
app.get('/test', async () => {
const {
raw, // Raw "cookie" from headers (string | undefined)
getCookie, // Cookie getter ((name) => string | null)
} = useCookies()
console.log(getCookie('session'))
// Prints the value of the cookie with the name "session"
})
```
## Authorization
The `useAuthorization` function provides helpers for working with authorization headers:
```js
import { useAuthorization } from '@wooksjs/event-http'
app.get('/test', async () => {
const {
authorization, // The raw value of the "authorization" header (string | undefined)
type, // The authentication type (Bearer/Basic) (() => string | null)
credentials, // The credentials that follow the auth type (() => string | null)
is, // Checks auth type: is('basic'), is('bearer'), etc. ((type) => boolean)
basicCredentials, // Parsed basic auth credentials (() => { username, password } | null)
} = useAuthorization()
if (is('basic')) {
const { username, password } = basicCredentials()
console.log({ username, password })
} else if (is('bearer')) {
const token = credentials()
console.log({ token })
} else {
// Unknown or empty authorization header
}
})
```
Note: `authorization` is a plain string value. All other properties are lazy functions — they compute on first call and cache the result. The `is()` argument is typed as `KnownAuthType` (`'basic' | 'bearer'`), but any other string is also accepted.
## Accept Header
The `useAccept` composable checks the request's `Accept` header for MIME type support:
```js
import { useAccept } from '@wooksjs/event-http'
app.get('/test', () => {
const { accept, has } = useAccept()
// Use short names for common types
if (has('json')) {
return { data: 'json response' }
} else if (has('html')) {
return 'html response
'
}
// Or full MIME types for anything else
if (has('image/webp')) {
// ...
}
})
```
Short names: `'json'` (application/json), `'html'` (text/html), `'xml'` (application/xml), `'text'` (text/plain) — typed as `KnownAcceptType`. Full MIME strings are also accepted.
The `accept` property returns the raw `Accept` header value.
## Body Size Limits
Request body reading is protected by configurable limits:
| Limit | 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 (`0` disables the timeout) |
The default values are exported as `DEFAULT_LIMITS` from `@wooksjs/event-http`.
`rawBody()` transparently decompresses `gzip`, `deflate`, and `br` request bodies (including stacked encodings) and always resolves to the decompressed `Buffer`, cached across calls — that is why the `maxInflated` and `maxRatio` limits exist.
### App-level configuration
```js
import { createHttpApp } from '@wooksjs/event-http'
const app = createHttpApp({
requestLimits: {
maxCompressed: 50 * 1024 * 1024, // 50 MB
maxInflated: 100 * 1024 * 1024, // 100 MB
maxRatio: 200,
readTimeoutMs: 30_000,
},
})
```
### Per-request overrides
Override limits inside a handler before reading the body:
```js
import { useRequest } from '@wooksjs/event-http'
app.post('/upload', async () => {
const {
setMaxCompressed,
setMaxInflated,
setMaxRatio,
setReadTimeoutMs,
rawBody,
} = useRequest()
// Raise limits for this route only
setMaxCompressed(50 * 1024 * 1024) // 50 MB
setMaxInflated(100 * 1024 * 1024) // 100 MB
const body = await rawBody()
return { size: body.length }
})
```
Per-request setters use copy-on-write — they do not mutate the app-level configuration. Each setter has a matching getter (`getMaxCompressed()`, `getMaxInflated()`, `getMaxRatio()`, `getReadTimeoutMs()`) returning the currently effective value.
### What happens on violation
When a limit is violated, `rawBody()` throws an `HttpError` that the framework renders automatically as an error response:
| Condition | Error |
|-----------|-------|
| Body exceeds `maxCompressed` (or `maxInflated` when uncompressed) | `413` Payload Too Large |
| Decompressed body exceeds `maxInflated` | `413` Inflated body too large |
| Compression ratio exceeds `maxRatio` | `413` Compression ratio too high |
| Unsupported `Content-Encoding` | `415` Unsupported Content-Encoding |
| No data received for `readTimeoutMs` (the timer resets on every received chunk) | `408` Request body timeout |
## Body Parser
The implementation of the body parser is isolated into a separate package
called `@wooksjs/http-body`. For more details on using the body parser, refer to the [Body Parser section](../body.md).
---
URL: "wooks.moost.org/webapp/composables/response.html"
LLMS_URL: "wooks.moost.org/webapp/composables/response.md"
---
# Response Composables
Wooks HTTP implements a response renderer that interprets handler return values and automatically manages `Content-Type` and `Content-Length` headers.
You can control all aspects of the response through the `useResponse()` composable, which returns an `HttpResponse` instance with chainable methods.
## Content
[[toc]]
## Plain Response
The simplest way to respond is to return a value from the handler function.
Whatever is returned becomes the response body. Objects are JSON-stringified with appropriate headers set automatically.
Example:
```js
app.get('string_response', () => {
return 'hello world!';
// responds with:
// 200
// Content-Length: 12
// Content-Type: text/plain
// hello world!
});
app.get('json_response', () => {
return { value: 'hello world!' };
// responds with:
// 200
// Content-Length: 24
// Content-Type: application/json
// {"value":"hello world!"}
});
```
**Supported response types:**
1. `string` (text/plain)
2. `object/array` (application/json)
3. `boolean` / `number` (text/plain)
4. `Uint8Array` / `Buffer` (sent as-is; set `Content-Type` yourself — none is set by default)
5. `Readable` stream (you must specify `Content-Type` yourself)
6. `fetch` `Response` (streams body to client response)
Instead of returning a value, you can also set the body explicitly on the response instance — `useResponse().setBody(data)` (chainable) or the `body` property.
## Raw Response
If you need to take full control of the response, you can get the raw `ServerResponse` via `useResponse()`.
When you do this without passthrough, the framework will not process the handler's return value.
Example:
```js
import { useResponse } from '@wooksjs/event-http';
app.get('test', () => {
const response = useResponse();
const res = response.getRawRes();
res.writeHead(200, {});
res.end('ok');
});
```
If you want the raw `ServerResponse` but still want the framework to manage the response lifecycle,
pass `true` for passthrough:
```js
import { useResponse } from '@wooksjs/event-http';
app.get('test', () => {
const response = useResponse();
const res = response.getRawRes(true); // passthrough: framework still manages response // [!code hl]
// Use res for reading or side effects, but still return a value:
return 'ok';
});
```
## Headers
::: tip
This documentation presumes that you are aware of what Response Headers are used for.
If it's not the case please see [RFC7231](https://www.rfc-editor.org/rfc/rfc7231#section-7)
:::
The `useResponse()` composable provides header management methods directly on the `HttpResponse` instance. All setters are chainable.
Example:
```js
import { useResponse } from '@wooksjs/event-http';
app.get('test', () => {
const response = useResponse();
response
.setContentType('application/json')
.setHeader('server', 'My Awesome Server v1.0')
.enableCors();
return '{ "value": "OK" }';
});
```
**Available methods:**
| Method | Description |
|--------|-------------|
| `setHeader(name, value)` | Sets a response header |
| `setHeaders(headers)` | Batch-sets multiple headers from a record |
| `getHeader(name)` | Gets a response header value |
| `removeHeader(name)` | Removes a response header |
| `headers()` | Returns all response headers |
| `setContentType(value)` | Sets the `Content-Type` header |
| `enableCors(origin?)` | Sets `Access-Control-Allow-Origin` (defaults to `*`) |
## Default Headers & Security Headers
You can pre-populate response headers for every request via the `defaultHeaders` option on `createHttpApp`. The `securityHeaders()` utility provides a curated set of recommended HTTP security headers.
### App-level defaults
```js
import { createHttpApp, securityHeaders } from '@wooksjs/event-http';
// Apply recommended security headers to all responses
const app = createHttpApp({ defaultHeaders: securityHeaders() });
// Customize individual headers
const app = createHttpApp({
defaultHeaders: securityHeaders({
contentSecurityPolicy: false, // disable CSP
referrerPolicy: 'strict-origin-when-cross-origin', // override default
}),
});
// Or use plain headers (no preset)
const app = createHttpApp({ defaultHeaders: { 'x-custom': 'value' } });
```
### Per-endpoint override
Handlers can override or remove default headers using `setHeader()`, `setHeaders()`, or `removeHeader()`:
```js
app.get('/api/data', () => {
const response = useResponse();
response.setHeaders(securityHeaders({
contentSecurityPolicy: "default-src 'self' cdn.example.com",
}));
return { data: 'hello' };
});
```
### `securityHeaders(opts?)` defaults
| Header | Default Value | Option Key |
|--------|--------------|------------|
| `Content-Security-Policy` | `default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'` | `contentSecurityPolicy` |
| `Cross-Origin-Opener-Policy` | `same-origin` | `crossOriginOpenerPolicy` |
| `Cross-Origin-Resource-Policy` | `same-origin` | `crossOriginResourcePolicy` |
| `Referrer-Policy` | `no-referrer` | `referrerPolicy` |
| `X-Content-Type-Options` | `nosniff` | `xContentTypeOptions` |
| `X-Frame-Options` | `SAMEORIGIN` | `xFrameOptions` |
**Opt-in only** (not included by default):
| Header | Option Key |
|--------|------------|
| `Strict-Transport-Security` | `strictTransportSecurity` |
::: warning
HSTS (`Strict-Transport-Security`) is not included by default because it can lock users out if the site is not fully on HTTPS. Enable it explicitly: `securityHeaders({ strictTransportSecurity: 'max-age=31536000; includeSubDomains' })`.
:::
Each option accepts a `string` (override value) or `false` (disable that header).
## Cookies
::: tip
This documentation presumes that you are aware of what Cookies are and what the
additional cookie attributes are used for. If it's not the case please see [RFC6265](https://www.rfc-editor.org/rfc/rfc6265#section-4.1)
:::
Set outgoing cookies via the `HttpResponse` instance:
```js
import { useResponse } from '@wooksjs/event-http';
app.get('test', () => {
const response = useResponse();
response.setCookie('session', 'value', {
expires: '2029-01-01', // Date | string | number
maxAge: '1h', // number | TTimeMultiString
domain: 'my-domain', // string
path: '/home', // string
secure: true, // boolean
httpOnly: true, // boolean
sameSite: 'Lax', // boolean | 'Lax' | 'None' | 'Strict'
});
return 'ok';
});
```
**Available methods:**
| Method | Description |
|--------|-------------|
| `setCookie(name, value, attrs?)` | Sets a cookie with optional attributes |
| `getCookie(name)` | Gets a previously set cookie's data |
| `removeCookie(name)` | Removes a cookie from the set list |
| `clearCookies()` | Removes all cookies from the set list |
| `setCookieRaw(rawValue)` | Sets a raw `Set-Cookie` header value |
## Status
Control the response status code via `useResponse()`:
```js
import { useResponse } from '@wooksjs/event-http';
app.get('test', () => {
const response = useResponse();
response.setStatus(201);
// or use the property directly:
// response.status = 201;
console.log(response.status); // 201
return 'response with status 201';
});
```
If you don't set a status, Wooks auto-assigns one based on the HTTP method and response body:
| Condition | Status |
|-----------|--------|
| `GET` with body | 200 OK |
| `POST` / `PUT` with body | 201 Created |
| `PATCH` / `DELETE` with body | 202 Accepted |
| No body | 204 No Content |
### Status code constants
Two named helpers are exported from `@wooksjs/event-http` so you can avoid magic numbers:
- `EHttpStatusCode` — a numeric enum of status codes (`EHttpStatusCode.Created === 201`, `EHttpStatusCode.NotFound === 404`, …).
- `httpStatusCodes` — a record mapping each numeric code to its human-readable description (`httpStatusCodes[201] === 'Created'`).
```js
import { useResponse, EHttpStatusCode, httpStatusCodes } from '@wooksjs/event-http';
app.post('/items', () => {
useResponse().setStatus(EHttpStatusCode.Created); // 201
return { created: true };
});
httpStatusCodes[404]; // 'Not Found'
```
Both are plain values — use them anywhere a status code is needed (e.g. `useResponse().setStatus(...)` or `new HttpError(...)`). The same `EHttpStatusCode` enum drives the auto-status table above.
## Error Responses
Throw an `HttpError` from a handler to produce an error response:
```js
import { HttpError } from '@wooksjs/event-http';
app.get('admin', () => {
throw new HttpError(403, 'Access denied');
});
```
The constructor takes a status code (default `500`) and a message string or a structured body object. The rendered body always has the shape `{ statusCode, message, error }`; extra fields of a structured body are merged in:
```js
throw new HttpError(400, { statusCode: 400, message: 'Validation failed', fields: ['name'] });
// { statusCode: 400, message: 'Validation failed', error: 'Bad Request', fields: ['name'] }
```
The structured body type requires `statusCode`, but the rendered value always comes from the constructor's status-code argument.
Any other thrown error is converted to `HttpError(500)` with the error's message.
### Content negotiation
The default response class (`WooksHttpResponse`) renders errors based on the request's `Accept` header:
- `application/json` — JSON body (also the fallback when nothing matches)
- `text/html` — a branded HTML error page
- `text/plain` — plain text
To change the branding of the HTML error page (name, version, link, logo), call `WooksHttpResponse.registerFramework({ version, poweredBy, link, image })`. To replace error rendering entirely, subclass `WooksHttpResponse` (override `renderError`) and pass it to the app: `createHttpApp({ responseClass: MyResponse })`.
### Multiple handlers on one route
When several handlers are registered for the same route, a thrown error advances processing to the next handler — silently for `HttpError`, with a logged error otherwise. Only the last handler's error is rendered as the response.
## Cache-Control
::: tip
If you don't know what Cache-Control is and what it is used for, please read [RFC7231](https://www.rfc-editor.org/rfc/rfc7231#section-7.1)
:::
The `HttpResponse` provides methods for setting cache-related headers:
```js
import { useResponse } from '@wooksjs/event-http';
app.get('static/*', () => {
const response = useResponse();
response.setAge('2h 15m');
response.setExpires('2025-05-05');
response.setCacheControl({
mustRevalidate: true,
noCache: false,
noStore: false,
noTransform: true,
public: true,
private: 'field',
proxyRevalidate: true,
maxAge: '3h 30m 12s',
sMaxage: '2h 27m 54s',
});
});
```
**Available methods:**
| Method | Description |
|--------|-------------|
| `setCacheControl(data)` | Sets the `Cache-Control` header from a directive object |
| `setAge(value)` | Sets the `Age` header (accepts `number` or time string like `'2h 15m'`) |
| `setExpires(value)` | Sets the `Expires` header (accepts `Date`, `string`, or `number`) |
| `setPragmaNoCache(value?)` | Sets `Pragma: no-cache` |
The standalone `renderCacheControl(data)` utility (exported from `@wooksjs/event-http`) renders the same directive object into a `Cache-Control` header value string.
## Advanced Members
A few more `HttpResponse` members are useful when integrating with other tooling:
| Member | Description |
|--------|-------------|
| `responded` | `true` once the response has been sent |
| `sendError(error, ctx)` | Renders and sends an `HttpError` (called automatically when a handler throws) |
| `toWebResponse()` | Builds a Web Standard `Response` from the accumulated state (used by [programmatic fetch](../fetch.md)) |
The `recordToWebHeaders(record)` utility (exported from `@wooksjs/event-http`) converts a Node-style headers record (`Record`) into Web Standard `Headers`.
## Proxy
You can feed a `fetch` response to your response by simply returning it from your handler.
For advanced proxy functionality, use the separate package `@wooksjs/http-proxy`. See the [Proxy documentation](../proxy.md).
## Serve Static
Static file serving is provided by the separate package `@wooksjs/http-static`. See the [Serve Static documentation](../static.md).
---
URL: "wooks.moost.org/webapp/express.html"
LLMS_URL: "wooks.moost.org/webapp/express.md"
---
# Express Integration
Use Wooks composables with [Express](https://expressjs.com). This adapter lets you register Wooks-style route handlers on top of an existing Express app — unmatched requests automatically fall through to Express middleware.
::: info
Source code and issues: [github.com/wooksjs/express-adapter](https://github.com/wooksjs/express-adapter)
:::
[[toc]]
## Install
```bash
npm install @wooksjs/express-adapter @wooksjs/event-http wooks express
```
## Quick Start
```ts
import express from 'express'
import { WooksExpress } from '@wooksjs/express-adapter'
import { useRouteParams, useRequest, HttpError } from '@wooksjs/event-http'
const app = express()
const wooks = new WooksExpress(app)
// Return values become the response body
wooks.get('/hello/:name', () => {
const { get } = useRouteParams()
return { hello: get('name') }
})
// Async handlers work out of the box
wooks.post('/upload', async () => {
const { rawBody } = useRequest()
const body = await rawBody()
return { received: body.length }
})
// Throw HttpError for error responses
wooks.get('/protected', () => {
throw new HttpError(403, 'Forbidden')
})
app.listen(3000, () => console.log('listening on 3000'))
```
## How It Works
`WooksExpress` extends `WooksHttp` and registers itself as Express middleware. When a request comes in:
1. Wooks checks if a matching route is registered
2. If matched — the Wooks handler runs with full composable support
3. If not matched — the request falls through to the next Express middleware
This means you can **mix Wooks routes with regular Express routes and middleware**:
```ts
import express from 'express'
import { WooksExpress } from '@wooksjs/express-adapter'
import cors from 'cors'
const app = express()
// Express middleware works as usual
app.use(cors())
// Wooks handles these routes
const wooks = new WooksExpress(app)
wooks.get('/api/users', () => {
return [{ id: 1, name: 'Alice' }]
})
// Body parsing for Express routes — register after WooksExpress
app.use(express.json())
// Express handles this route
app.get('/legacy', (req, res) => {
res.send('handled by express')
})
app.listen(3000)
```
::: warning Register WooksExpress before body parsers
Body-parsing middleware (`express.json()`, `body-parser`) consumes the request stream — if it runs before Wooks, `rawBody()` / `useBody()` receive an empty body on Wooks routes. Instantiate `WooksExpress` before registering body parsers. Wooks parses bodies on demand, so global body parsers are unnecessary for Wooks routes.
:::
## API
### `new WooksExpress(expressApp, options?)`
Creates a new adapter instance and registers Wooks middleware on the Express app.
| Option | Type | Default | Description |
| ---------------- | -------------------------- | ------- | -------------------------------------------------------------------------------- |
| `raise404` | `boolean` | `false` | Return 404 from Wooks for unmatched routes instead of falling through to Express |
| `onNotFound` | `() => unknown` | — | Custom handler for unmatched routes |
| `logger` | `TConsoleBase` | — | Custom logger instance |
| `router` | `object` | — | Router options (`ignoreTrailingSlash`, `ignoreCase`, `cacheLimit`) |
| `requestLimits` | `object` | — | Default request body size limits |
| `defaultHeaders` | `Record` | — | Headers added to every response |
| `responseClass` | `typeof WooksHttpResponse` | — | Custom response class, see [Error Responses](/webapp/composables/response#error-responses) |
### Route Methods
```ts
wooks.get(path, handler)
wooks.post(path, handler)
wooks.put(path, handler)
wooks.patch(path, handler)
wooks.delete(path, handler)
wooks.head(path, handler)
wooks.options(path, handler)
wooks.all(path, handler)
```
Handlers take **no arguments** — use [composables](/webapp/composables/) to access request data:
```ts
wooks.get('/users/:id', () => {
const { get } = useRouteParams()
const { method, url, rawBody, getIp } = useRequest()
const headers = useHeaders()
const response = useResponse()
response.setHeader('x-custom', 'value')
return { id: get('id') }
})
```
### `wooks.listen(port)`
Starts the Express server and returns a promise that resolves when listening.
```ts
await wooks.listen(3000)
```
### `wooks.close()`
Stops the server. Only stops servers started via `wooks.listen()` — if you start the server with `app.listen()` yourself, keep the returned `Server` and close it directly (or pass it to `wooks.attachServer(server)` first).
```ts
await wooks.close()
```
## Available Composables
All `@wooksjs/event-http` composables work inside Wooks handlers — see the [Composables reference](/webapp/composables/).
---
URL: "wooks.moost.org/webapp/fastify.html"
LLMS_URL: "wooks.moost.org/webapp/fastify.md"
---
# Fastify Integration
Use Wooks composables with [Fastify](https://fastify.dev). This adapter lets you register Wooks-style route handlers on top of an existing Fastify app — unmatched requests automatically fall through to Fastify's not-found handler.
::: info
Source code and issues: [github.com/wooksjs/fastify-adapter](https://github.com/wooksjs/fastify-adapter)
:::
[[toc]]
## Install
```bash
npm install @wooksjs/fastify-adapter @wooksjs/event-http wooks fastify
```
## Quick Start
```ts
import Fastify from 'fastify'
import { WooksFastify } from '@wooksjs/fastify-adapter'
import { useRouteParams, useRequest, HttpError } from '@wooksjs/event-http'
const app = Fastify()
const wooks = new WooksFastify(app)
// Return values become the response body
wooks.get('/hello/:name', () => {
const { get } = useRouteParams()
return { hello: get('name') }
})
// Async handlers work out of the box
wooks.post('/upload', async () => {
const { rawBody } = useRequest()
const body = await rawBody()
return { received: body.length }
})
// Throw HttpError for error responses
wooks.get('/protected', () => {
throw new HttpError(403, 'Forbidden')
})
app.listen({ port: 3000 }, () => console.log('listening on 3000'))
```
## How It Works
`WooksFastify` extends `WooksHttp` and registers a catch-all route in Fastify. When a request comes in:
1. Wooks checks if a matching route is registered
2. If matched — the Wooks handler runs with full composable support
3. If not matched — the request falls through to Fastify's not-found handler
This means you can use Wooks composables for your route handlers while still leveraging Fastify's plugin ecosystem:
```ts
import Fastify from 'fastify'
import { WooksFastify } from '@wooksjs/fastify-adapter'
const app = Fastify()
// Wooks handles these routes
const wooks = new WooksFastify(app)
wooks.get('/api/users', () => {
return [{ id: 1, name: 'Alice' }]
})
app.listen({ port: 3000 })
```
::: warning Content-type parsers are replaced
`WooksFastify` replaces all Fastify content-type parsers with a raw-buffer parser so Wooks can read bodies on demand. Native Fastify routes on the same instance receive `request.body` as a `Buffer` instead of parsed JSON — re-add parsers or parse manually in native routes if you mix them.
:::
## API
### `new WooksFastify(fastifyApp, options?)`
Creates a new adapter instance and registers a catch-all route on the Fastify app.
| Option | Type | Default | Description |
| ---------------- | -------------------------- | ------- | --------------------------------------------------------------------------------- |
| `raise404` | `boolean` | `false` | Return 404 from Wooks for unmatched routes instead of using Fastify's not-found |
| `onNotFound` | `() => unknown` | — | Custom handler for unmatched routes |
| `logger` | `TConsoleBase` | — | Custom logger instance |
| `router` | `object` | — | Router options (`ignoreTrailingSlash`, `ignoreCase`, `cacheLimit`) |
| `requestLimits` | `object` | — | Default request body size limits |
| `defaultHeaders` | `Record` | — | Headers added to every response |
| `responseClass` | `typeof WooksHttpResponse` | — | Custom response class, see [Error Responses](/webapp/composables/response#error-responses) |
### Route Methods
```ts
wooks.get(path, handler)
wooks.post(path, handler)
wooks.put(path, handler)
wooks.patch(path, handler)
wooks.delete(path, handler)
wooks.head(path, handler)
wooks.options(path, handler)
wooks.all(path, handler)
```
Handlers take **no arguments** — use [composables](/webapp/composables/) to access request data:
```ts
wooks.get('/users/:id', () => {
const { get } = useRouteParams()
const { method, url, rawBody, getIp } = useRequest()
const headers = useHeaders()
const response = useResponse()
response.setHeader('x-custom', 'value')
return { id: get('id') }
})
```
### `wooks.listen(...args)`
Starts the Fastify server and returns a promise that resolves when listening.
```ts
await wooks.listen({ port: 3000 })
```
### `wooks.close()`
Stops the server.
```ts
await wooks.close()
```
## Available Composables
All `@wooksjs/event-http` composables work inside Wooks handlers — see the [Composables reference](/webapp/composables/).
---
URL: "wooks.moost.org/webapp/fetch.html"
LLMS_URL: "wooks.moost.org/webapp/fetch.md"
---
# Programmatic Fetch
Invoke route handlers in-process using the Web Standard `Request`/`Response` API — no TCP connection, no serialization overhead. The full dispatch pipeline runs: route matching, context creation, composables, error handling.
[[toc]]
## Why
Server-side rendering (SSR), in-process API calls, and testing all need to call route handlers programmatically. Without `fetch()`, the only options are:
- **Loopback HTTP request** — wasteful TCP round-trip for an in-process call
- **Calling handlers directly** — bypasses the entire Wooks pipeline (routing, composables, error handling)
- **Duplicating logic** in a separate service layer
`fetch()` and `request()` solve this by exposing the application as a `(Request) → Response` function — the same pattern used by Hono, H3/Nitro, and Bun.
## Basic Usage
### `app.request()`
The convenience method for most use cases. Accepts a URL string (relative paths auto-prefixed with `http://localhost`), plus optional `RequestInit`:
```ts
import { createHttpApp } from '@wooksjs/event-http'
const app = createHttpApp()
app.get('/api/users', () => {
return [{ id: 1, name: 'Alice' }]
})
// Programmatic invocation — full pipeline, zero TCP
const res = await app.request('/api/users')
const users = await res.json()
// [{ id: 1, name: 'Alice' }]
```
POST with body:
```ts
const res = await app.request('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Bob' }),
headers: { 'content-type': 'application/json' },
})
```
### `app.fetch()`
Accepts a full Web Standard `Request` object. Use this when you need complete control over the request:
```ts
const res = await app.fetch(
new Request('http://localhost/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Bob' }),
headers: { 'content-type': 'application/json' },
})
)
```
Both methods return a standard `Response` object with status, headers, and body — or `null` if no route matched (see [Unmatched Routes](#unmatched-routes)).
## SSR Header Forwarding
When `fetch()` is called from within an existing HTTP handler (e.g., during SSR), identity headers are **automatically forwarded** from the calling request to the programmatic request. This means the inner API call sees the same user session as the page request — no manual header copying needed.
```ts
import { useAuthorization } from '@wooksjs/event-http'
app.get('/dashboard', async () => {
// The user's authorization and cookie headers are
// automatically forwarded to this inner call
const res = await app.request('/api/user-data')
const data = await res.json()
return renderPage(data)
})
app.get('/api/user-data', () => {
// useAuthorization() sees the original user's Bearer token
const { credentials } = useAuthorization()
return fetchUserData(credentials())
})
```
### Default Forwarded Headers
By default, the following headers are forwarded:
- `authorization` — auth identity (Bearer tokens, Basic auth)
- `cookie` — session state
- `accept-language` — user's language preference
- `x-forwarded-for` — client IP chain
- `x-request-id` — request tracing
### Customizing Header Forwarding
Configure globally via `createHttpApp` options:
```ts
// Forward only specific headers
const app = createHttpApp({
forwardHeaders: ['authorization', 'cookie', 'x-custom-auth'],
})
// Disable forwarding entirely
const app = createHttpApp({
forwardHeaders: false,
})
```
### Header Precedence
Headers explicitly set on the programmatic request **always override** forwarded headers:
```ts
app.get('/page', async () => {
// Even though the page request has authorization,
// the inner call uses the explicitly provided override
const res = await app.request('/api/data', {
headers: { authorization: 'Bearer service-token' },
})
return res.json()
})
```
## Cookie Propagation
When an inner API call sets cookies (e.g., refreshing a session token), those cookies are **automatically propagated** to the outer response. This ensures cookies reach the browser even when set by an inner programmatic call during SSR:
```ts
import { useResponse } from '@wooksjs/event-http'
app.get('/dashboard', async () => {
// If /api/auth/refresh sets a new session cookie,
// it will appear on the /dashboard response sent to the browser
await app.request('/api/auth/refresh')
return renderDashboard()
})
app.get('/api/auth/refresh', () => {
const response = useResponse()
response.setCookie('session', newToken, { httpOnly: true })
return { ok: true }
})
```
Cookies from multiple inner calls are all collected on the outer response.
## Response Isolation
Each `fetch()` invocation creates a fully isolated context. The inner response's status code, headers (except propagated cookies), and body do not affect the outer response:
```ts
app.get('/page', async () => {
const inner = await app.request('/api/might-fail')
// Inner 404 does not make the page 404
if (inner.status === 404) {
return renderNotFound()
}
return renderPage(await inner.json())
})
```
Route parameters, URL, method, and all composables are scoped to each invocation.
## Performance Optimizations
### `json()` — Zero-Cost Deserialization
When a handler returns a plain object, `response.json()` returns the **original object reference** — no `JSON.stringify` → `JSON.parse` round-trip:
```ts
app.get('/api/data', () => {
return { users: [{ id: 1 }], total: 1 }
})
const res = await app.request('/api/data')
const data = await res.json()
// `data` is the exact same object the handler returned — not a parsed copy
```
### `text()` — Direct String Access
For string responses, `response.text()` returns the pre-computed string directly without reading the body stream:
```ts
app.get('/greeting', () => 'Hello World!')
const res = await app.request('/greeting')
const text = await res.text() // returns the string directly
```
## Raw ServerResponse Access
Handlers that call `getRawRes()` and write directly to the Node.js `ServerResponse` work during programmatic fetch. Writes are intercepted and captured into the Web `Response`:
```ts
import { useResponse } from '@wooksjs/event-http'
app.get('/raw', () => {
const res = useResponse().getRawRes()
res.writeHead(200, { 'content-type': 'text/plain', 'x-custom': 'value' })
res.write('chunk 1')
res.end('chunk 2')
})
const res = await app.request('/raw')
await res.text() // 'chunk 1chunk 2'
res.headers.get('x-custom') // 'value'
```
::: warning Limitations of raw access
When using `getRawRes()` during programmatic fetch, the `json()` / `text()` optimizations do not apply — the response body is captured as raw bytes.
:::
## Unmatched Routes
When no route matches, `fetch()` and `request()` return `null` instead of a 404 response. This lets callers distinguish "no route exists" from "a handler explicitly returned 404":
```ts
const res = await app.request('/maybe-exists')
if (!res) {
// No route matched — handle accordingly
}
if (res.status === 404) {
// A handler matched but threw HttpError(404) — real 404
}
```
::: info `onNotFound` is ignored by `fetch()`
The `onNotFound` option only applies to the HTTP server callback (`getServerCb()`). Programmatic `fetch()` always returns `null` for unmatched routes — the caller decides what to do.
:::
## Middleware Integration
### `getServerCb(onNoMatch?)`
When integrating Wooks with another server (e.g., Vite dev server), pass an `onNoMatch` callback to `getServerCb()`. It receives the raw `req`/`res` when no Wooks route matches, allowing you to forward the request to the next middleware:
```ts
import { createServer } from 'http'
import { createViteServer } from 'vite'
import { createHttpApp } from '@wooksjs/event-http'
const app = createHttpApp()
const vite = await createViteServer({ server: { middlewareMode: true } })
const server = createServer(
app.getServerCb((req, res) => {
// No Wooks route matched — let Vite handle it
vite.middlewares.handle(req, res)
})
)
app.attachServer(server)
server.listen(3000)
```
When you create the server yourself, call `app.attachServer(server)` so `app.close()` can stop it — `app.getServer()` returns the attached server later. If your app registers WebSocket upgrade routes, also wire the upgrade event: `server.on('upgrade', app.getUpgradeCb())`.
When `onNoMatch` is provided, it takes priority over the `onNotFound` option. This means you can use both — `onNotFound` handles 404s for standalone server mode, while `onNoMatch` bypasses it for middleware integration.
Without the callback, unmatched routes fall through to `onNotFound` (if set), or return a 404 response — the standard behavior for standalone servers.
## Limitations
### Body is Read Eagerly
The request body is fully read into memory before the handler runs. This is fine for typical API payloads (JSON, form data) but not suitable for streaming large file uploads via `fetch()`.
---
URL: "wooks.moost.org/webapp/h3.html"
LLMS_URL: "wooks.moost.org/webapp/h3.md"
---
# H3 Integration
Use Wooks composables with [H3](https://h3.unjs.io/) — register Wooks-style route handlers on top of an existing H3 app. Unmatched requests automatically fall through to H3 handlers.
::: info
Source code and issues: [github.com/wooksjs/h3-adapter](https://github.com/wooksjs/h3-adapter)
:::
[[toc]]
## Install
```bash
npm install @wooksjs/h3-adapter @wooksjs/event-http wooks h3
```
## Quick Start
```ts
import { H3, toNodeHandler } from 'h3/node'
import { WooksH3 } from '@wooksjs/h3-adapter'
import { useRouteParams } from '@wooksjs/event-http'
import { createServer } from 'http'
const app = new H3()
const wooks = new WooksH3(app)
wooks.get('/hello/:name', () => {
const { get } = useRouteParams()
return { hello: get('name') }
})
const handler = toNodeHandler(app)
createServer(handler).listen(3000, () => {
console.log('Server running on http://localhost:3000')
})
```
## How It Works
When a request comes in:
1. Wooks checks if a matching route is registered
2. If matched — the Wooks handler runs with full composable support
3. If not matched — the request falls through to H3 handlers
This means you can **mix Wooks routes with H3 routes** side by side:
```ts
const app = new H3()
const wooks = new WooksH3(app)
// Wooks route — uses composables
wooks.get('/wooks-route', () => {
const { get } = useRouteParams()
return 'handled by wooks'
})
// H3 route — uses h3 event API
app.on('GET', '/h3-route', (event) => {
return 'handled by h3'
})
```
::: warning Node runtime required
`WooksH3` reads the raw Node `req`/`res` from `event.node`, so serve the app via `toNodeHandler()` / `serve()` from `h3/node` (as in the Quick Start). On edge/worker runtimes Wooks routes are skipped and requests fall through to H3.
:::
## API
### `new WooksH3(h3App, options?)`
Creates a new adapter instance on top of an H3 app.
| Option | Type | Default | Description |
| ---------------- | -------------------------- | ------- | -------------------------------------------------------------------------- |
| `raise404` | `boolean` | `false` | Return 404 for unmatched routes instead of falling through to H3 |
| `onNotFound` | `() => unknown` | — | Custom handler for unmatched routes |
| `logger` | `TConsoleBase` | — | Custom logger instance |
| `router` | `object` | — | Router options (`ignoreTrailingSlash`, `ignoreCase`, `cacheLimit`) |
| `requestLimits` | `object` | — | Default request body size limits |
| `defaultHeaders` | `Record` | — | Headers added to every response |
| `responseClass` | `typeof WooksHttpResponse` | — | Custom response class, see [Error Responses](/webapp/composables/response#error-responses) |
### Route Methods
```ts
wooks.get(path, handler)
wooks.post(path, handler)
wooks.put(path, handler)
wooks.patch(path, handler)
wooks.delete(path, handler)
wooks.head(path, handler)
wooks.options(path, handler)
wooks.all(path, handler)
```
Handlers take **no arguments** — use [composables](/webapp/composables/) to access request data:
```ts
wooks.get('/users/:id', () => {
const { get } = useRouteParams()
return { userId: get('id') }
})
```
## Usage Examples
### Reading Request Body
```ts
wooks.post('/data', async () => {
const { rawBody } = useRequest()
const body = await rawBody()
return { received: body.toString() }
})
```
### Setting Response Headers
```ts
wooks.get('/custom-headers', () => {
const response = useResponse()
response.setHeader('x-powered-by', 'wooks')
response.setStatus(200)
return { ok: true }
})
```
### Error Handling
```ts
wooks.get('/protected', () => {
throw new HttpError(403, 'Forbidden')
})
```
## Options Example
```ts
const wooks = new WooksH3(app, {
raise404: true,
onNotFound: () => {
const response = useResponse()
response.setStatus(404)
return { error: 'not found' }
},
defaultHeaders: {
'x-powered-by': 'wooks',
},
requestLimits: {
maxCompressed: 1_048_576,
maxInflated: 10_485_760,
},
})
```
## Available Composables
All `@wooksjs/event-http` composables work inside Wooks handlers — see the [Composables reference](/webapp/composables/).
---
URL: "wooks.moost.org/webapp/integrations"
LLMS_URL: "wooks.moost.org/webapp/integrations.md"
---
# Framework Integrations
Already have a project running on Express, Fastify, or H3? You don't have to rewrite it to try the Wooks way. Our integration adapters let you register Wooks-style route handlers on top of your existing app — adopt composables gradually, one route at a time.
## The Idea
Each integration adapter wraps your framework's app instance and hooks into its request pipeline. When a request arrives:
1. The adapter checks if a matching **Wooks route** exists.
2. If matched — the handler runs with full access to Wooks composables (`useRequest`, `useRouteParams`, `useResponse`, etc.).
3. If not matched — the request **falls through** to the framework's own routing and middleware, completely unchanged.
This means Wooks routes and framework-native routes live side by side. You can migrate incrementally or just use Wooks for the parts where composables shine.
## Common Pattern
Regardless of the framework, the setup always follows the same shape:
```ts
// 1. Create your framework app as usual
const app = createFrameworkApp()
// 2. Attach Wooks to it
const wooks = new WooksAdapter(app)
// 3. Register routes — handlers are plain functions, no req/res
wooks.get('/hello/:name', () => {
const { get } = useRouteParams()
return { hello: get('name') }
})
// 4. Start the server through the framework
app.listen(3000)
```
Handlers take **no arguments**. Request data is accessed through composables — on demand, typed, cached per request. Return values become the response body automatically.
## Available Integrations
| Framework | Package | Adapter Class |
|-----------|---------|---------------|
| [Express](/webapp/express) | `@wooksjs/express-adapter` | `WooksExpress` |
| [Fastify](/webapp/fastify) | `@wooksjs/fastify-adapter` | `WooksFastify` |
| [H3](/webapp/h3) | `@wooksjs/h3-adapter` | `WooksH3` |
All adapters share the same composable API — once you learn it for one framework, it works everywhere.
---
URL: "wooks.moost.org/webapp/introduction.html"
LLMS_URL: "wooks.moost.org/webapp/introduction.md"
---
# Introduction to Wooks HTTP
`@wooksjs/event-http` is the HTTP adapter for Wooks. It gives you a Node.js HTTP server where every handler is a plain function that returns its response, and every piece of request data is available through composables — on demand, typed, cached.
## Quick Picture
```ts
import { createHttpApp } from '@wooksjs/event-http'
import { useBody } from '@wooksjs/http-body'
const app = createHttpApp()
app.post('/users', async () => {
const { parseBody } = useBody()
const user = await parseBody<{ name: string }>()
return { created: user.name } // → 201, application/json
})
app.listen(3000)
```
No middleware to register. No `req`/`res` parameters. The body is parsed only when `parseBody()` is called. The response status and content type are inferred from the return value and HTTP method.
## What You Get
| Composable | What it provides |
|------------|-----------------|
| `useRequest()` | Method, URL, headers, raw body, IP, request limits |
| `useResponse()` | Status, headers, cookies, cache control — one chainable API |
| `useBody()` | JSON, URL-encoded, multipart, text parsing — on demand |
| `useRouteParams()` | Typed route parameters |
| `useCookies()` | Incoming cookie values |
| `useUrlParams()` | URL query parameters |
| `useAuthorization()` | Authorization header parsing (Basic, Bearer) |
Plus `@wooksjs/http-static` for file serving and `@wooksjs/http-proxy` for reverse proxy.
## Routing
Built on [`@prostojs/router`](https://github.com/prostojs/router) — parametric routes, wildcards, regex constraints, and multiple wildcards in a single path. [Fastest on real-world route patterns](/benchmarks/router). See [Routing](/webapp/routing) for details.
## Build Your Own Composables
`defineWook` is the same primitive that powers every built-in composable. Your custom logic works exactly the same way — cached per request, typed, composable with everything else. See [Custom Composables](/webapp/more-hooks) for examples.
## Next Steps
- [Quick Start](/webapp/) — Spin up a server in minutes.
- [What is Wooks?](/wooks/what) — How composables, context, and `defineWook` work under the hood.
- [Why Wooks?](/wooks/why) — The design decisions and when Wooks is the right choice.
- [Comparison](/webapp/comparison) — Concrete differences vs Express, Fastify, and h3.
- [Benchmarks](/benchmarks/wooks-http) — Performance data against Express, Fastify, h3, and Hono.
---
URL: "wooks.moost.org/webapp/logging.html"
LLMS_URL: "wooks.moost.org/webapp/logging.md"
---
---
URL: "wooks.moost.org/webapp/more-hooks.html"
LLMS_URL: "wooks.moost.org/webapp/more-hooks.md"
---
# Custom Composables
In this guide, we'll build custom composables for Wooks HTTP to demonstrate how to extend the framework with your own reusable logic.
## Example: `useUserProfile`
Let's create a composable that resolves the user profile from the Authorization header.
```ts
import { useAuthorization } from '@wooksjs/event-http'
import { defineWook, cached } from '@wooksjs/event-core'
interface TUser {
username: string
age: number
}
// Simulated database lookup
function readUser(username: string): Promise {
// Return the user profile from the database
return db.findUser(username)
}
export const useUserProfile = defineWook((ctx) => {
const { basicCredentials } = useAuthorization(ctx)
const username = basicCredentials()?.username
return {
username,
userProfile: async () => {
if (!username) return null
return readUser(username)
},
}
})
// Usage in a handler
app.get('/user', async () => {
const { username, userProfile } = useUserProfile()
console.log('username =', username)
const data = await userProfile()
return { user: data }
})
```
The `defineWook` wrapper ensures the factory function runs once per event context. Calling `useUserProfile()` multiple times in the same request returns the same cached object.
## Example: Custom Header Helper
Here's a composable that provides a getter/setter interface for a specific response header:
```ts
import { useResponse } from '@wooksjs/event-http'
function useHeaderRef(name: string) {
const response = useResponse()
return {
get value(): string | undefined {
return response.getHeader(name) as string | undefined
},
set value(val: string | number) {
response.setHeader(name, val)
},
}
}
// Usage
app.get('/test', () => {
const server = useHeaderRef('x-server')
server.value = 'My Awesome Server v1.0'
return 'ok'
})
// Response headers:
// x-server: My Awesome Server v1.0
```
## Example: Request Timing
A composable that tracks how long request processing takes:
```ts
import { defineWook } from '@wooksjs/event-core'
import { useResponse } from '@wooksjs/event-http'
export const useRequestTiming = defineWook(() => {
const start = Date.now()
const response = useResponse()
return {
elapsed: () => Date.now() - start,
setTimingHeader: () => {
response.setHeader('x-response-time', `${Date.now() - start}ms`)
},
}
})
// Usage
app.get('/data', async () => {
const { setTimingHeader } = useRequestTiming()
const data = await fetchData()
setTimingHeader()
return data
})
```
Since `defineWook` caches per context, `Date.now()` captures the time when the composable is first accessed in the request, giving you accurate timing.
## HTTP Context Access
Composable factories receive the event context as an argument (`ctx` above). Two `@wooksjs/event-http` exports help when you need the context explicitly:
- `useHttpContext()` — returns the current HTTP event context (`EventContext`).
- `httpKind` — the HTTP event-kind definition; its `keys` (`req`, `response`, `requestLimits`) address the slots seeded into every HTTP context, e.g. `ctx.get(httpKind.keys.req)`.
For creating HTTP contexts manually (e.g. in a custom adapter), see [Custom Wooks Adapter](/wooks/advanced/wooks-adapter).
---
URL: "wooks.moost.org/webapp/proxy.html"
LLMS_URL: "wooks.moost.org/webapp/proxy.md"
---
# Proxy Requests
The `@wooksjs/http-proxy` package provides a convenient way to proxy requests in Wooks HTTP.
It allows you to easily proxy requests to another server or API.
## Installation
To use the proxy functionality, you need to install the `@wooksjs/http-proxy` package:
```bash
npm install @wooksjs/http-proxy
```
## Usage
Once installed, you can import and use the `useProxy` composable function in your Wooks application.
Example:
```js
import { useProxy } from '@wooksjs/http-proxy'
app.get('/to-proxy', () => {
const proxy = useProxy()
return proxy('https://target-website.com/target-path?query=123')
})
```
The `useProxy` function returns a function that you can call with the target URL you want to proxy.
The function will make the proxy request and return the `fetch` response from the target server.
By default the proxy forwards **no** request headers or cookies and applies **no** upstream response headers — forwarding is opt-in.
Pass `reqHeaders: { allow: '*' }` (or any controls object) to forward incoming headers, `reqCookies` to forward cookies,
and `resHeaders`/`resCookies` to apply upstream response headers/cookies to the outgoing response.
When you return the fetch response from the handler, the upstream status, `content-type`, and `content-length` are applied automatically.
## Choose which cookies/headers to pass
You can choose the cookies and headers that are passed in the proxy request by passing
the `reqCookies` and `reqHeaders` options to the `proxy` function returned by `useProxy`.
Providing a controls object defaults `allow` to `'*'`, so everything passes unless blocked.
Example:
```js
import { useProxy } from '@wooksjs/http-proxy';
app.get('/to-proxy', () => {
const proxy = useProxy();
return proxy('https://target-website.com/target-path?query=123', {
reqHeaders: { block: ['referer'] }, // Block the referer header
reqCookies: { block: '*' }, // Block all request cookies
});
});
```
In the example above, the referer header is blocked, and all request cookies are blocked from being passed in the proxy request.
## Change Response
The proxy function returned by `useProxy` behaves like a regular fetch call and returns a `fetch` response.
You can modify the response or access its data before returning it from the handler.
Example:
```js
import { useProxy } from '@wooksjs/http-proxy';
app.get('/to-proxy', async () => {
const proxy = useProxy();
const response = await proxy('https://myapi.com/json-api');
const data = { ...(await response.json()), newField: 'new value' };
return data;
});
```
In the example above, the `proxy` function is used to make the proxy request,
and the response is then modified by adding a new field before returning it from the handler.
## Advanced Options
The `proxy` function accepts advanced options for customizing the proxy behavior.
You can specify options such as the request method, filtering request and response headers/cookies, overwriting data, and enabling debug mode.
Example:
```js
import { useProxy } from '@wooksjs/http-proxy';
import { useRequest } from '@wooksjs/event-http';
app.get('*', async () => {
const proxy = useProxy();
const { url } = useRequest();
const fetchResponse = await proxy('https://www.google.com' + url, {
method: 'GET', // Optional method, defaults to the original request method
// Restrict the upstream host (recommended when the target is built from request input)
allowedHosts: ['www.google.com'],
// Filtering options for request headers/cookies
reqHeaders: { block: ['referer'] },
reqCookies: { allow: ['cookie-to-pass-upstream'] },
// Filtering options for response headers/cookies
resHeaders: { overwrite: { 'x-proxied-by': 'wooks-proxy' } },
resCookies: { allow: ['cookie-to-pass-downstream'] },
debug: true, // Enable debug mode to print proxy paths and headers/cookies
});
return fetchResponse;
});
```
The `fetchResponse` can be returned directly from the handler, or you can modify it before returning.
::: warning Securing the upstream host
The proxy fixes the upstream host to whatever `new URL(target)` resolves, so request **path** data cannot redirect the request to another host. When you build the target from request input (as in `'https://www.google.com' + url` above), also set `allowedHosts` to the hostnames you trust — the proxy then rejects any resolved host outside the list with `502`. Only `http:`/`https:` targets are allowed. `allowedHosts` accepts exact hostname strings (case-insensitive) or `RegExp` entries; an empty array denies every host.
:::
## Header & Cookie Controls
The four filtering options — `reqHeaders`, `reqCookies`, `resHeaders`, `resCookies` — all take the same **controls** object. Each field is optional and they compose (allow/block filter, then `overwrite` is applied):
| Field | Type | Behavior |
|-------|------|----------|
| `allow` | `Array` or `'*'` | Allowlist of keys to forward. Strings match by name; `RegExp` matches the key. `'*'` allows all. |
| `block` | `Array` or `'*'` | Blocklist of keys to suppress. `'*'` blocks all. |
| `overwrite` | `Record` or `(data) => Record` | Override specific key→value pairs, or pass a function that receives the current map and returns the replacement map. |
`block` wins over `allow`; `allow` defaults to `'*'` when a controls object is given. `overwrite` runs after filtering and can still force any key.
Some headers are always stripped regardless of `allow`:
- **Request:** `connection`, `accept-encoding`, `content-length`, `upgrade-insecure-requests`, `cookie` — forward cookies via `reqCookies`, not `reqHeaders`.
- **Response:** `transfer-encoding`, `content-encoding`, `set-cookie` — apply cookies via `resCookies`.
```js
import { useProxy } from '@wooksjs/http-proxy';
app.get('*', () => {
const proxy = useProxy();
return proxy('https://upstream.example.com', {
// RegExp + string allowlist
reqHeaders: { allow: [/^x-/, 'authorization'] },
// block all upstream cookies
resCookies: { block: '*' },
// function form — derive headers from the existing set
resHeaders: {
overwrite: (headers) => ({ ...headers, 'x-proxied-by': 'wooks-proxy' }),
},
});
});
```
Other `proxy` options: `method` (override the proxied HTTP method, defaults to the incoming request's method), `allowedHosts` (allowlist of upstream hostnames — strings or `RegExp` — rejecting any other resolved host with `502`), and `debug` (log proxy paths, headers, and cookies).
---
URL: "wooks.moost.org/webapp/routing.html"
LLMS_URL: "wooks.moost.org/webapp/routing.md"
---
# Routing
Routing is the initial step in event processing, responsible for directing the event context
to the appropriate event handler.
Routes can be categorized as static or parametric, with parameters parsed from parametric
routes and passed to the handler.
::: info
Wooks utilizes [@prostojs/router](https://github.com/prostojs/router) for routing, and its
documentation is partially included here for easy reference. See [Router Benchmarks](/benchmarks/router) for performance data.
:::
The router effectively parses URIs and quickly identifies the corresponding handler.
## Content
[[toc]]
## Registering Routes
Each HTTP method has a shortcut on the app instance, plus `all()` for matching every method and the generic `on()`:
```js
import { createHttpApp } from '@wooksjs/event-http'
const app = createHttpApp()
app.get('/path', () => 'ok') // also: post, put, patch, delete, head, options
app.all('/path', () => 'ok') // matches every HTTP method
app.on('GET', '/path', () => 'ok') // generic form
```
### Router options
Router behavior is configured via the `router` option of `createHttpApp`:
```js
const app = createHttpApp({
router: {
ignoreTrailingSlash: true, // `/path` and `/path/` match the same route
ignoreCase: true, // case-insensitive route matching
cacheLimit: 1000, // max number of parsed routes to cache
},
})
```
## Parametric routes
Parameters in routes begin with a colon (`:`).
To include a colon in the path without defining a parameter, it must be escaped
with a backslash (`/api/colon\\:novar`).
Parameters can be separated using a hyphen (`/api/:key1-:key2`).
Regular expressions can also be specified for parameters (`/api/time/:hours(\\d{2})h:minutes(\\d{2})m`).
```js
// Simple single param
app.get('/api/vars/:key', () => 'ok')
// Two params separated with a hyphen
app.get('/api/vars/:key1-:key2', () => 'ok')
// Two params with regex
app.get('/api/time/:hours(\\d{2})h:minutes(\\d{2})m', () => 'ok')
// Two params separated with a slash
app.get('/api/user/:name1/:name2', () => 'ok')
// Three params with the same name (leads to an array as a value)
app.get('/api/array/:name/:name/:name', () => 'ok')
```
## Wildcards
Wildcards are denoted by an asterisk (`*`) and offer several options:
1. They can be placed at the beginning, middle, or end of a path.
1. Multiple wildcards can be used.
1. Wildcards can be combined with parameters.
1. Regular expressions can be passed to wildcards.
```js
// The most common usage (matches all URIs that start with `/static/`)
app.get('/static/*', () => 'ok')
// Matches all URIs that start with `/static/` and end with `.js`
app.get('/static/*.js', () => 'ok')
// Matches all URIs that start with `/static/` and have `/test/` in the middle
app.get('/static/*/test/*', () => 'ok')
// Matches all URIs that start with `/static/[numbers]`
app.get('/static/*(\\d+)', () => 'ok')
```
### Optional Parameters
A parametric (wildcard) route can include optional parameters. If you wish to define optional parameters, they should appear at the end of the route. It is not permitted to have obligatory parameters after an optional parameter, and static segments should not appear after optional parameters, except when using `-` and `/` as separators between parameters.
Optional parameters may be omitted when matching a route, and the corresponding handler will still be found.
**Note:**
A parametric route with optional parameters is treated as a wildcard during lookup, which can reduce routing performance. Please use this feature carefully.
To define a parameter (wildcard) as optional, simply add `?` at the end.
```ts
// Optional parameter
app.get('/api/vars/:optionalKey?', () => 'ok')
// Optional wildcard
app.get('/api/vars/:*?', () => 'ok')
// Several optional parameters
app.get('/api/vars/:v1/:v2?/:v3?', () => 'ok')
```
In the above example, the router allows routes with optional parameters to be defined using the `?` symbol at the end of the parameter name. For instance, `/api/vars/myKey` and `/api/vars/` are both valid routes for the first example. Similarly, the second example allows routes like `/api/vars/param1/param2` and `/api/vars/` to be matched. Lastly, the third example permits routes with one, two, or three parameters to be matched, with any combination of parameters being optional.
## Retrieving URI params
Access route parameters with the `useRouteParams` composable:
```ts
function useRouteParams<
T extends Record = Record
>(): {
params: T
get: (name: K) => T[K]
}
```
```ts
import { useRouteParams } from '@wooksjs/event-http'
app.get('hello/:name', () => {
const { get } = useRouteParams()
return `Hello ${get('name')}!`
})
```
For repeated param names, it returns an array:
```ts
app.get('hello/:name/:name', () => {
const { get } = useRouteParams()
return get('name') // Array of names
})
```
For wildcards, the name of the param is `*`:
```ts
app.get('hello/*', () => {
const { get } = useRouteParams()
return get('*') // Returns everything that follows `hello/`
})
```
Multiple wildcards are stored as an array, similar to repeated param names.
## Path builders
When defining a new route, a path builder is returned.
Path builders are used to construct paths based on URI params.
::: code-group
```js [javascript]
const { getPath: pathBuilder } = app.get('/api/path', () => 'ok')
console.log(pathBuilder()) // /api/path
const { getPath: userPathBuilder } = app.get('/api/user/:name', () => 'ok')
console.log(
userPathBuilder({
name: 'John',
})
) // /api/user/John
const { getPath: wildcardBuilder } = app.get('/static/*', () => 'ok')
console.log(
wildcardBuilder({
'*': 'index.html',
})
) // /static/index.html
const { getPath: multiParamsBuilder } = app.get('/api/asset/:type/:type/:id', () => 'ok')
console.log(
multiParamsBuilder({
type: ['CJ', 'REV'],
id: '443551',
})
) // /api/asset/CJ/REV/443551
```
```ts [typescript]
interface MyParamsType {
name: string
}
const { getPath: userPathBuilder } = app.get('/api/user/:name', () => 'ok')
console.log(userPathBuilder({
name: 'John'
}))
// /api/user/John
```
:::
## Query Parameters
Query Parameters or URL Search Parameters are not part of the URI path processed by the router.
The router simply ignores everything after `?` or `#`.
To access query parameters, you can use the `useUrlParams` composable function from `@wooksjs/event-http`.
For more details, refer to the [Query Parameters section](./composables/request.md#query-parameters).
---
URL: "wooks.moost.org/webapp/static.html"
LLMS_URL: "wooks.moost.org/webapp/static.md"
---
# Serve Static
The `@wooksjs/http-static` package provides the serveFile function,
which allows you to serve static files in Wooks HTTP.
It returns a readable stream from the file system.
Features:
- Returns a readable stream
- Prepares all the neccessary response headers (like content-length, content-type etc)
- Can handle etag
- Can handle ranges
## Installation
To use the static file serving functionality, you need to install the `@wooksjs/http-static` package:
```bash
npm install @wooksjs/http-static
```
## Usage
Once installed, you can import the `serveFile` function and use it in your Wooks application.
Example:
```js
import { serveFile } from '@wooksjs/http-static';
app.get('static/file.txt', () => {
// ...
return serveFile('file.txt', { baseDir: 'public' });
});
```
The `serveFile` function takes the file path as the first argument and accepts an optional
options object as the second argument. It returns a readable stream of the file content.
## Options
The `options` object allows you to customize the behavior of the file serving. It provides the following properties:
- `headers`: A `Record` of additional response headers to set.
- `cacheControl`: An object of Cache-Control directives (e.g. `{ maxAge: '10m', public: true }`); `maxAge`/`sMaxage` accept seconds or time strings.
- `expires`: The Expires header value to specify the expiration date/time of the file.
- `pragmaNoCache`: A boolean value indicating whether to add the Pragma: no-cache header.
- `baseDir`: The base directory path for resolving the file path. Defaults to the process working directory (`process.cwd()`); it is also the boundary for the path-traversal check.
- `defaultExt`: The default file extension appended when the path has none (e.g. `defaultExt: 'html'` serves `about` as `about.html`). The fallback is tried once, then cleared, so it does not recurse indefinitely.
- `listDirectory`: A boolean value indicating whether to render an HTML directory listing when the path is a directory.
- `index`: The filename of the index file to automatically serve when the path is a directory (e.g. `'index.html'`). If the URL has no trailing slash, the request is first redirected (`302`) to add one.
- `allowDotDot`: When `true`, allows `../` parent-directory traversal in the requested path. **Defaults to `false`** — leave it off unless you fully control the input, as enabling it lets a crafted path escape `baseDir`.
::: warning Path traversal
With the default `allowDotDot: false`, any path that resolves outside `baseDir` is rejected with a `403` before touching the filesystem — `../` segments that stay inside `baseDir` are still allowed. Only enable `allowDotDot` for trusted, server-controlled paths — never for a segment taken straight from the URL (`get('*')`).
:::
## Built-in file server example:
Here's an example of using the `serveFile` function to create a built-in file server:
```js
import { useRouteParams } from '@wooksjs/event-http';
import { serveFile } from '@wooksjs/http-static';
app.get('static/*', () => {
const { get } = useRouteParams();
return serveFile(get('*'), { cacheControl: { maxAge: '10m' } });
});
```
See the [Cache Control documentation](./composables/response.md#cache-control) for details on configuring cache directives.
## Conditional Requests & Ranges
`serveFile` handles HTTP caching and partial transfers automatically — you don't wire anything up:
- **ETag / Last-Modified.** Every file response gets an `ETag` (derived from inode, size, and mtime) and a `Last-Modified` header. On the next request the browser sends `If-None-Match` / `If-Modified-Since`; when the file is unchanged, `serveFile` responds **`304 Not Modified`** with no body.
- **Range requests.** When the client sends a `Range` header, `serveFile` streams only the requested byte range with **`206 Partial Content`** and a `Content-Range` header. An `If-Range` header is honored — if the validator no longer matches, the full file is sent instead. Every file response advertises `Accept-Ranges: bytes`.
These make `serveFile` suitable for large downloads, video/audio seeking, and resumable transfers without extra configuration.
---
URL: "wooks.moost.org/webapp/testing.html"
LLMS_URL: "wooks.moost.org/webapp/testing.md"
---
# Testing
`@wooksjs/event-http` provides two approaches for testing: **programmatic fetch** for integration tests (full pipeline) and **test context** for unit tests (composable isolation).
[[toc]]
## Integration Testing with `fetch()` / `request()`
The recommended approach for testing routes end-to-end. No HTTP server, no TCP overhead — the full dispatch pipeline runs in-process:
```ts
import { describe, it, expect } from 'vitest'
import { createHttpApp, useRequest, useResponse, HttpError } from '@wooksjs/event-http'
import { useRouteParams } from '@wooksjs/event-core'
import { Wooks } from 'wooks'
describe('Users API', () => {
const app = createHttpApp(undefined, new Wooks())
app.get('/api/users/:id', () => {
const { params } = useRouteParams()
return { id: params.id }
})
app.post('/api/users', async () => {
const { rawBody } = useRequest()
const body = JSON.parse((await rawBody()).toString())
return { created: true, name: body.name }
})
it('resolves route params', async () => {
const res = await app.request('/api/users/42')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ id: '42' })
})
it('reads POST body', async () => {
const res = await app.request('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Alice' }),
headers: { 'content-type': 'application/json' },
})
expect(res.status).toBe(201)
expect(await res.json()).toEqual({ created: true, name: 'Alice' })
})
it('returns null for unknown routes', async () => {
const res = await app.request('/api/nope')
expect(res).toBeNull()
})
it('captures response headers and cookies', async () => {
app.get('/api/with-headers', () => {
const response = useResponse()
response.setHeader('x-custom', 'value')
response.setCookie('session', 'tok')
return 'ok'
})
const res = await app.request('/api/with-headers')
expect(res.headers.get('x-custom')).toBe('value')
expect(res.headers.getSetCookie()[0]).toContain('session=tok')
})
it('handles errors', async () => {
app.get('/api/forbidden', () => {
throw new HttpError(403, 'Access denied')
})
const res = await app.request('/api/forbidden')
expect(res.status).toBe(403)
const body = await res.json()
expect(body.message).toBe('Access denied')
})
})
```
::: tip Isolated Router
Pass a `new Wooks()` as the second argument to `createHttpApp()` so each test suite gets its own router. Without this, all suites share a global singleton and routes can collide:
```ts
import { Wooks } from 'wooks'
const app = createHttpApp(undefined, new Wooks())
```
:::
### Testing SSR Flows
Test nested programmatic calls to verify header forwarding and cookie propagation:
```ts
import { useAuthorization, useResponse } from '@wooksjs/event-http'
it('forwards auth to inner API calls', async () => {
app.get('/page', async () => {
const inner = await app.request('/api/me')
return inner.json()
})
app.get('/api/me', () => {
const { authorization } = useAuthorization()
return { auth: authorization }
})
const res = await app.request('/page', {
headers: { authorization: 'Bearer tok123' },
})
expect(await res.json()).toEqual({ auth: 'Bearer tok123' })
})
it('propagates cookies from inner calls to outer response', async () => {
app.get('/page', async () => {
await app.request('/api/refresh')
return 'page content'
})
app.get('/api/refresh', () => {
useResponse().setCookie('session', 'new-token')
return 'ok'
})
const res = await app.request('/page')
expect(res.headers.getSetCookie()[0]).toContain('session=new-token')
})
```
## Unit Testing with `prepareTestHttpContext`
For testing composables in isolation — without routing or response rendering:
```ts
import { prepareTestHttpContext } from '@wooksjs/event-http'
import { useRequest, useCookies, useAuthorization } from '@wooksjs/event-http'
const run = prepareTestHttpContext({
url: '/api/users/42?page=2',
method: 'GET',
headers: {
authorization: 'Bearer tok123',
cookie: 'session=abc',
},
params: { id: '42' },
})
run(() => {
const { method, url } = useRequest()
expect(method).toBe('GET')
expect(url).toBe('/api/users/42?page=2')
const { getCookie } = useCookies()
expect(getCookie('session')).toBe('abc')
const { credentials } = useAuthorization()
expect(credentials()).toBe('tok123')
})
```
### Options
```ts
interface TTestHttpContext {
url: string // Request URL (e.g. '/api/users?page=1')
method?: string // HTTP method (default: 'GET')
headers?: Record // Request headers
params?: Record // Pre-set route parameters
rawBody?: string | Buffer // Pre-seed request body
requestLimits?: TRequestLimits // Custom body size limits
defaultHeaders?: Record // Pre-populate response headers
}
```
### Testing Body Parsing
```ts
const run = prepareTestHttpContext({
url: '/api/data',
method: 'POST',
headers: { 'content-type': 'application/json' },
rawBody: JSON.stringify({ name: 'Alice' }),
})
run(async () => {
const { rawBody } = useRequest()
const body = JSON.parse((await rawBody()).toString())
expect(body.name).toBe('Alice')
})
```
## When to Use Which
| Approach | Use Case | Runs routing? | Runs response rendering? |
|----------|----------|:---:|:---:|
| `app.request()` / `app.fetch()` | Integration tests, SSR flows | Yes | Yes |
| `prepareTestHttpContext()` | Unit tests for composables | No | No |
---
URL: "wooks.moost.org/wf"
LLMS_URL: "wooks.moost.org/wf.md"
---
# Quick Start
This guide walks you through building a complete workflow from scratch.
## Installation
```bash
npm install wooks @wooksjs/event-wf
```
## Step 1: Create the App
Every workflow app starts with `createWfApp`. The generic parameter defines the **context type** — the shared state that all steps in a workflow can read and modify.
```ts
import { createWfApp, useWfState } from '@wooksjs/event-wf'
import { useRouteParams } from '@wooksjs/event-core'
// Define the shape of your workflow context
interface OrderContext {
items: string[]
total: number
discount: number
status: string
}
const app = createWfApp()
```
`createWfApp(options?)` accepts:
- `onNotFound` — fallback flow resolver called when `start()` receives an unknown flow id; it returns `{ id, init? }` pointing to a registered flow. Without it, `start()` throws `Unknown schemaId: `.
- `logger` — custom logger instance.
- `router` — router options for the underlying Wooks instance.
- `strictStepIds` — throw (instead of warn) on duplicate step ids. See [Steps](/wf/steps#fail-loudly-with-strictstepids).
## Step 2: Define Steps
Steps are the building blocks. Each step has an **id** and a **handler** function.
```ts
// A simple step that calculates the total
app.step('calculate-total', {
handler: (ctx) => {
ctx.total = ctx.items.length * 10 // $10 per item
},
})
// A parametric step — the discount percentage comes from the step id
app.step('apply-discount/:percent', {
handler: (ctx) => {
const percent = Number(useRouteParams().get('percent'))
ctx.discount = ctx.total * (percent / 100)
ctx.total -= ctx.discount
},
})
// A step that uses the composable API
app.step('finalize', {
handler: () => {
const { ctx } = useWfState()
const context = ctx()
context.status = context.total > 0 ? 'ready' : 'empty'
},
})
```
Note how `apply-discount/:percent` uses route-style parameters — when called as `apply-discount/15`, the `percent` parameter resolves to `"15"`.
## Step 3: Define a Flow
A flow is a schema that wires steps together. It's just an array — the engine executes steps in order.
```ts
app.flow('process-order', [
'calculate-total',
{
condition: 'total > 50', // only apply discount for orders over $50
steps: ['apply-discount/10'],
},
'finalize',
])
```
## Step 4: Run It
```ts
const output = await app.start('process-order', {
items: ['shirt', 'pants', 'shoes', 'jacket', 'hat', 'belt'],
total: 0,
discount: 0,
status: '',
})
console.log(output.finished) // true
console.log(output.state.context)
// { items: [...], total: 54, discount: 6, status: 'ready' }
```
The second argument to `start()` is the initial context. Every step in the flow reads and mutates this same object.
## What Just Happened?
1. `calculate-total` set `total` to 60 (6 items × $10)
2. The condition `total > 50` was true, so `apply-discount/10` ran and subtracted 10% ($6)
3. `finalize` set `status` to `'ready'`
The output includes the full final state under `output.state.context`, and `output.finished` tells you whether the workflow completed or paused for input.
## Next Steps
- [Introduction](/wf/introduction) — what workflows are and the core concepts
- [Why Workflows](/wf/why) — the motivation and design rationale
- [Steps](/wf/steps) — handlers, parametric routing, string handlers, retriable errors
- [Flows](/wf/flows) — conditions, loops, subflows, break/continue
- [Input & Resume](/wf/input-and-resume) — pause workflows for user input, resume later
- [Composables](/wf/composables) — built-in and custom composables for steps
- [HTTP Integration](/wf/http-integration) — start workflows from HTTP handlers
- [Outlets](/wf/outlets) — pause to HTTP forms or emails with state tokens
---
URL: "wooks.moost.org/wf/composables.html"
LLMS_URL: "wooks.moost.org/wf/composables.md"
---
# Composables
Wooks composables let you encapsulate repeatable patterns into functions that "just work" inside any step handler — no parameter drilling, no manual context passing. They use `AsyncLocalStorage` under the hood, so they resolve the current workflow context automatically.
[[toc]]
## Built-in Composables
These are available out of the box in workflow step handlers:
| Composable | Import | What it provides |
|------------|--------|-----------------|
| `useWfState()` | `@wooksjs/event-wf` | Workflow context, input, schema/step ids, resume flag |
| `useWfFinished()` | `@wooksjs/event-wf` | Set the HTTP response for workflow completion (used with [outlets](/wf/outlets)) |
| `useWfStrategy()` | `@wooksjs/event-wf` | Read / swap the active state strategy name for the next outlet pause (see [Swapping strategies](/wf/outlets#swapping-strategies)) |
| `useWfOutlet()` | `@wooksjs/event-wf` | Access the outlet registry — `getOutlets()`, `getOutlet(name)` (advanced) |
| `useRouteParams()` | `@wooksjs/event-core` | Route parameters from parametric step ids |
| `useEventId()` | `@wooksjs/event-core` | Unique per-execution UUID |
| `useLogger()` | `@wooksjs/event-core` | Event-scoped logger instance |
```ts
import { useWfState } from '@wooksjs/event-wf'
import { useRouteParams, useEventId, useLogger } from '@wooksjs/event-core'
app.step('process/:type', {
handler: () => {
const { ctx, input } = useWfState()
const type = useRouteParams().get('type')
const logger = useLogger()
const { getId } = useEventId()
logger.info(`[${getId()}] Processing type: ${type}`)
},
})
```
## Creating Custom Composables
When you find yourself repeating the same pattern across multiple steps, extract it into a composable.
### `defineWook` — The Core Primitive
`defineWook` creates a composable whose factory runs **once per workflow execution** and is cached for its lifetime:
```ts
import { defineWook } from '@wooksjs/event-core'
const useMyComposable = defineWook((ctx) => {
// This factory runs once per workflow execution.
// `ctx` is the current EventContext.
// Return an object with your composable's API.
return {
doSomething: () => { /* ... */ },
}
})
```
The returned function (`useMyComposable`) can be called from any step handler — it will always resolve to the same cached instance within a single execution.
### `key` — Mutable State
Use `key()` when you need a read/write slot that steps can modify during execution:
```ts
import { key, defineWook, current } from '@wooksjs/event-core'
const errorsKey = key('validation.errors')
export const useValidation = defineWook((ctx) => {
ctx.set(errorsKey, [])
return {
addError: (msg: string) => ctx.get(errorsKey).push(msg),
getErrors: () => ctx.get(errorsKey),
hasErrors: () => ctx.get(errorsKey).length > 0,
}
})
```
Now any step can collect and check validation errors:
```ts
app.step('validate-email', {
handler: () => {
const { ctx } = useWfState()
const { addError } = useValidation()
if (!ctx().email.includes('@')) {
addError('Invalid email address')
}
},
})
app.step('check-validation', {
handler: () => {
const { hasErrors, getErrors } = useValidation()
if (hasErrors()) {
return { inputRequired: { errors: getErrors(), retry: true } }
}
},
})
app.flow('submit-form', ['validate-email', 'validate-name', 'check-validation', 'save'])
```
### `cached` — Lazy Computed Values
Use `cached()` for derived values that should be computed once and reused:
```ts
import { cached, defineWook } from '@wooksjs/event-core'
const configSlot = cached(async (ctx) => {
// Expensive operation — runs once, result is cached
const res = await fetch('https://api.example.com/config')
return res.json()
})
export const useConfig = defineWook((ctx) => ({
getConfig: () => ctx.get(configSlot),
}))
```
### `cachedBy` — Parameterized Caching
Use `cachedBy()` when you need one cached result per unique key:
```ts
import { cachedBy } from '@wooksjs/event-core'
const fetchUser = cachedBy(async (userId: string, ctx) => {
const res = await fetch(`https://api.example.com/users/${userId}`)
return res.json()
})
export const useUsers = defineWook(() => ({
getUser: (id: string) => fetchUser(id),
}))
```
Calling `getUser('123')` twice in the same execution hits the API only once.
## Practical Examples
### Audit Trail
Track which steps executed and what they did — useful for compliance, debugging, or building activity feeds:
```ts
import { key, defineWook } from '@wooksjs/event-core'
interface AuditEntry {
step: string
timestamp: number
detail?: string
}
const auditKey = key('wf.audit')
export const useAuditLog = defineWook((ctx) => {
ctx.set(auditKey, [])
return {
record: (step: string, detail?: string) => {
ctx.get(auditKey).push({ step, timestamp: Date.now(), detail })
},
getLog: () => ctx.get(auditKey),
}
})
// Usage in steps:
app.step('approve', {
handler: () => {
const { ctx, input } = useWfState()
const { record } = useAuditLog()
const approved = input()
ctx().approved = approved ?? false
record('approve', `Decision: ${approved ? 'approved' : 'rejected'}`)
},
})
```
After the workflow finishes, read the full log from any step or from the caller:
```ts
app.flow('review', ['validate', 'approve', 'finalize'], '', () => {
useAuditLog().record('init', 'Workflow started')
})
```
### Notification Collector
Collect notifications across steps, then send them all at the end:
```ts
import { key, defineWook } from '@wooksjs/event-core'
interface Notification {
to: string
subject: string
body: string
}
const notificationsKey = key('wf.notifications')
export const useNotifications = defineWook((ctx) => {
ctx.set(notificationsKey, [])
return {
queue: (n: Notification) => ctx.get(notificationsKey).push(n),
getQueued: () => ctx.get(notificationsKey),
}
})
// Steps queue notifications:
app.step('approve-order', {
handler: () => {
const { ctx } = useWfState()
const { queue } = useNotifications()
const order = ctx()
queue({
to: order.customerEmail,
subject: 'Order approved',
body: `Your order #${order.id} has been approved.`,
})
},
})
// Final step sends them all:
app.step('send-notifications', {
handler: async () => {
const { getQueued } = useNotifications()
for (const n of getQueued()) {
await sendEmail(n.to, n.subject, n.body)
}
},
})
```
## Integrating with HTTP
Workflows create their own isolated event context by default, but can optionally **inherit** a parent context to share composables with the calling scope.
See [HTTP Integration](/wf/http-integration) for the full guide — starting workflows from HTTP handlers, inheriting auth context, pause/resume API patterns, and when to use each approach.
---
URL: "wooks.moost.org/wf/flows.html"
LLMS_URL: "wooks.moost.org/wf/flows.md"
---
# Flows
A flow is a schema that defines which [steps](/wf/steps) run, in what order, and under what conditions. Flows are **data** — plain arrays you can build, store, and compose.
[[toc]]
## Defining a Flow
```ts
app.flow('flow-id', [ ...steps ])
```
The simplest flow is a sequence of step ids:
```ts
app.step('validate', { handler: (ctx) => { /* ... */ } })
app.step('process', { handler: (ctx) => { /* ... */ } })
app.step('complete', { handler: (ctx) => { /* ... */ } })
app.flow('pipeline', ['validate', 'process', 'complete'])
```
Steps execute top to bottom. Each step receives the same shared context.
## Providing Input to Steps
You can hardcode input for a step directly in the flow schema:
```ts
app.flow('calculate', [
{ id: 'add', input: 5 },
{ id: 'add', input: 10 },
{ id: 'multiply', input: 2 },
])
```
This is useful when the same step is reused with different values across a flow.
Schema-hardcoded input is delivered to the handler's **second argument** (and to [string handlers](/wf/steps#string-handlers)) — it does not reach `useWfState().input()`. See [Hardcoding Input in Flows](/wf/input-and-resume#hardcoding-input-in-flows).
## Conditional Steps
Attach a `condition` to skip a step when the condition is false. A condition is either a string expression evaluated against the workflow context or a function `(ctx) => boolean | Promise`:
```ts
app.flow('process-order', [
'calculate-total',
{ id: 'apply-discount', condition: 'total > 100' },
'charge-payment',
])
// The same condition as a function:
app.flow('process-order-fn', [
'calculate-total',
{ id: 'apply-discount', condition: (ctx) => ctx.total > 100 },
'charge-payment',
])
```
`apply-discount` only runs if `context.total > 100`. Use string conditions when the flow must be serializable; use functions for type-safe logic. `while`, `break`, and `continue` (below) accept the same two forms.
## Subflows
A subflow is an anonymous group of steps nested inside a flow. Use subflows to apply a shared condition or loop to multiple steps at once.
```ts
app.flow('onboarding', [
'create-account',
{
steps: ['send-welcome-email', 'schedule-intro-call'],
},
'activate',
])
```
Without a condition, a subflow is just a grouping mechanism. It becomes powerful when combined with conditions or loops.
### Conditional Subflows
```ts
app.flow('onboarding', [
'create-account',
{
condition: 'plan === "premium"',
steps: ['assign-account-manager', 'send-premium-welcome'],
},
{
condition: 'plan !== "premium"',
steps: ['send-standard-welcome'],
},
'activate',
])
```
The entire subflow is skipped if its condition is false.
## Loops
Use `while` instead of `condition` to repeat a subflow as long as the expression is true:
```ts
app.flow('retry-until-success', [
{
while: 'attempts < 3 && !success',
steps: ['attempt-operation', 'check-result'],
},
'finalize',
])
```
The subflow repeats until `attempts >= 3` or `success` becomes truthy.
### `break` — Exit a Loop Early
```ts
app.flow('search', [
{
while: 'page < maxPages',
steps: [
'fetch-page',
{ break: 'found' }, // exit if context.found is truthy
'increment-page',
],
},
'return-results',
])
```
When the `break` condition is met, execution jumps past the loop to the next step in the parent flow.
### `continue` — Skip to Next Iteration
```ts
app.flow('process-batch', [
{
while: 'index < items.length',
steps: [
'load-item',
{ continue: 'item.skip' }, // skip this item, go to next iteration
'process-item',
'save-result',
],
},
])
```
When the `continue` condition is met, the remaining steps in the current iteration are skipped and the loop restarts from the top.
## Flow Prefix
If all steps in a flow share a common prefix, you can set it once:
```ts
app.step('order/validate', { handler: (ctx) => { /* ... */ } })
app.step('order/charge', { handler: (ctx) => { /* ... */ } })
app.step('order/fulfill', { handler: (ctx) => { /* ... */ } })
// Instead of:
app.flow('process-order', ['order/validate', 'order/charge', 'order/fulfill'])
// Use a prefix:
app.flow('process-order', ['validate', 'charge', 'fulfill'], 'order')
```
The third argument to `flow()` is prepended to every step id in the schema.
## Flow Initialization
The fourth argument is an `init` callback that runs inside the workflow context before **every** execution of the flow — on each `start()` _and_ each `resume()`. Make it idempotent, or it will overwrite state every time a paused run resumes:
```ts
app.flow('report', ['gather-data', 'format', 'send'], '', async () => {
const { ctx } = useWfState()
const context = ctx()
if (!context.reportId) {
context.startedAt = Date.now()
context.reportId = await generateId()
}
})
```
Use `init` to set up derived context values or run async setup. Composables like `useWfState()` are available inside `init`.
## Parametric Flows
Flow ids support the same routing syntax as steps:
```ts
app.flow('process/:type', ['validate', 'transform', 'save'])
await app.start('process/json', { data: '...' })
await app.start('process/csv', { data: '...' })
```
## Flow Output
Both `app.start()` and `app.resume()` return a `TFlowOutput` object:
```ts
const output = await app.start('my-flow', initialContext)
output.finished // true if the flow completed, false if it paused
output.state.context // the final (or current) context
output.state.schemaId // the flow id
output.state.indexes // position in the schema (for resuming)
output.inputRequired // set if the flow paused for input
output.error // set if a StepRetriableError was thrown
output.stepId // id of the last step the engine touched
output.resume?.(input) // shortcut to resume the flow
```
When `finished` is `false`, the flow paused because a step needs input or threw a retriable error. See [Input & Resume](/wf/input-and-resume) for how to continue execution.
## Gotchas
- **Register steps before flows.** `app.flow()` validates every step id in the schema at registration time and throws `Step "/" not found.` if a referenced step isn't registered yet.
- **Flow ids are unique per app.** Registering the same flow id twice throws `Workflow schema with id "" already registered.`
- **Parametric references are validated against parametric routes.** A schema entry like `'add/5'` is valid as long as a step `'add/:n'` is registered.
---
URL: "wooks.moost.org/wf/http-integration.html"
LLMS_URL: "wooks.moost.org/wf/http-integration.md"
---
# HTTP Integration
Workflows are transport-independent by default — they create their own isolated event context so they can be triggered from HTTP, a queue, a cron job, or a test. But when a workflow is started from an HTTP handler, you sometimes want step handlers to access data that's already been resolved in the HTTP scope (the authenticated user, parsed headers, request metadata).
There are two approaches: **pass data explicitly** via the workflow context, or **inherit the parent context** so HTTP composables work directly inside step handlers.
[[toc]]
## Approach 1: Pass Data Explicitly
The simplest and most portable approach. Extract what you need from the HTTP scope and pass it as part of the workflow context:
```ts
import { createHttpApp, useRequest } from '@wooksjs/event-http'
import { createWfApp, useWfState } from '@wooksjs/event-wf'
import { useBody } from '@wooksjs/http-body'
interface OrderContext {
orderId: string
items: string[]
total: number
status: string
triggeredBy: string // data from HTTP scope
}
const wf = createWfApp()
wf.step('calculate-total', {
handler: (ctx) => { ctx.total = ctx.items.length * 10 },
})
wf.step('finalize', {
handler: (ctx) => { ctx.status = 'confirmed' },
})
wf.flow('process-order', ['calculate-total', 'finalize'])
const http = createHttpApp()
http.post('/orders', async () => {
const { parseBody } = useBody()
const { getIp } = useRequest()
const body = await parseBody<{ orderId: string; items: string[] }>()
const output = await wf.start('process-order', {
orderId: body.orderId,
items: body.items,
total: 0,
status: 'pending',
triggeredBy: getIp(), // passed into workflow context
})
return { finished: output.finished, order: output.state.context }
})
http.listen(3000)
```
This is the right choice when:
- The workflow might be resumed later (in a different HTTP request or from a queue)
- You want workflows to be testable without an HTTP context
- You only need a few specific values from the HTTP scope
## Approach 2: Inherit the Parent Context
Pass `eventContext: current()` in the options to link the workflow to the **parent** event context. Internally, the workflow creates a child context with `parent: current()`, forming a parent chain. HTTP composables like `useRequest()`, `useCookies()`, or any custom composables you've built — all keep working inside step handlers because slot lookups traverse the parent chain automatically.
```ts
import { current } from '@wooksjs/event-core'
http.post('/orders', async () => {
const output = await wf.start(
'process-order',
{ orderId: '123', items: ['shirt'], total: 0, status: 'pending' },
{ eventContext: current() },
)
return { order: output.state.context }
})
```
Now step handlers can call HTTP composables directly — the child context delegates to the parent when a slot is not found locally:
```ts
import { useRequest } from '@wooksjs/event-http'
wf.step('finalize', {
handler: () => {
const { ctx } = useWfState()
const { getIp } = useRequest() // works via parent chain traversal
ctx().status = 'confirmed'
ctx().triggeredBy = getIp()
},
})
```
### Extracting User Info on First Step
A common pattern: your HTTP middleware resolves the authenticated user and caches it in the event context. With `eventContext`, the workflow's child context traverses the parent chain to access cached values without re-fetching:
```ts
import { current, defineWook, cached } from '@wooksjs/event-core'
import { useAuthorization } from '@wooksjs/event-http'
// Custom composable — resolves and caches user from auth header
const userSlot = cached(async (ctx) => {
const { is, credentials } = useAuthorization(ctx)
if (!is('bearer')) return null
const token = credentials()!
return fetchUserByToken(token) // your auth logic → { id, role, ... }
})
export const useCurrentUser = defineWook((ctx) => ({
getUser: () => ctx.get(userSlot),
}))
// HTTP handler — user is resolved here (and cached)
http.post('/workflows/start', async () => {
const { getUser } = useCurrentUser()
const user = await getUser()
if (!user) return { error: 'Unauthorized' }
const output = await wf.start(
'onboarding',
{ userId: user.id, role: user.role, steps: [] },
{ eventContext: current() },
)
return output.state.context
})
// Workflow step — accesses the same cached user, no re-fetch
wf.step('check-permissions', {
handler: async () => {
const { ctx } = useWfState()
const { getUser } = useCurrentUser() // same cached result
const user = await getUser()
if (user?.role === 'admin') {
ctx().skipApproval = true
}
},
})
```
The `useCurrentUser()` composable runs its factory **once per event context**. When the HTTP handler calls it, the result is cached in the parent context. When the workflow step calls it, the child context traverses the parent chain and finds the cached result — no second database/token lookup.
### When to Use Each Approach
| Scenario | Recommended |
|----------|------------|
| Workflow completes within a single HTTP request | Either works |
| Workflow pauses and resumes in a different request | Pass data explicitly |
| Steps need access to many HTTP composables | Inherit context |
| Workflow is triggered from non-HTTP sources too | Pass data explicitly |
| Steps need cached values from HTTP middleware (auth, user) | Inherit context |
| You want workflows to be testable without HTTP | Pass data explicitly |
You can combine both: inherit context for the initial `start()`, but rely on the workflow context for data that must survive a `resume()` in a later request.
## Pause and Resume via HTTP API
For workflows that pause for user input, you have two options:
### Option 1: Outlets (Recommended)
The **[outlets system](/wf/outlets)** handles state persistence, token management, and HTTP response building for you — a single endpoint handles both starting and resuming:
```ts
import {
createOutletHandler,
createHttpOutlet,
HandleStateStrategy,
WfStateStoreMemory,
} from '@wooksjs/event-wf'
const handle = createOutletHandler(wf)
http.post('/workflows', () =>
handle({
state: new HandleStateStrategy({ store: new WfStateStoreMemory() }),
outlets: [createHttpOutlet()],
})
)
```
The client sends `{ wfid: "signup" }` to start, and `{ wfs: "token", input: { ... } }` to resume. See the [Outlets guide](/wf/outlets) for the full API.
### Option 2: Manual Endpoints
If you need full control, wire start and resume endpoints yourself:
```ts
// In-memory store (use a database in production)
const workflows = new Map()
http.post('/workflows/start/:flowId', async () => {
const { parseBody } = useBody()
const flowId = useRouteParams().get('flowId')
const body = await parseBody>()
const output = await wf.start(flowId, body)
const id = crypto.randomUUID()
if (!output.finished) {
workflows.set(id, output.state)
}
return {
id,
finished: output.finished,
inputRequired: output.inputRequired,
context: output.state.context,
}
})
http.post('/workflows/resume/:id', async () => {
const { parseBody } = useBody()
const id = useRouteParams().get('id')
const { input } = await parseBody<{ input: unknown }>()
const state = workflows.get(id)
if (!state) return { error: 'Workflow not found' }
const output = await wf.resume(state, { input })
if (output.finished) {
workflows.delete(id)
} else {
workflows.set(id, output.state)
}
return {
id,
finished: output.finished,
inputRequired: output.inputRequired,
context: output.state.context,
}
})
```
The client flow:
1. `POST /workflows/start/signup` — starts the workflow, gets back `id` + `inputRequired`
2. Render a form based on `inputRequired`
3. `POST /workflows/resume/:id` with user input — gets next `inputRequired` or `finished: true`
4. Repeat until done
The workflow itself is completely unaware of HTTP. The HTTP layer is just the transport that shuttles state and input back and forth.
---
URL: "wooks.moost.org/wf/input-and-resume.html"
LLMS_URL: "wooks.moost.org/wf/input-and-resume.md"
---
# Input & Resume
Workflows can **pause** when a step needs input and **resume** later with that input. This is the key pattern for interactive workflows — user approvals, form wizards, external callbacks, and anything that can't complete in a single pass.
[[toc]]
## How Pausing Works
A step signals that it needs input in one of two ways:
**Static** — declare `input` on the step definition:
```ts
app.step('get-email', {
input: 'email', // always requires input
handler: () => {
const { ctx, input } = useWfState()
ctx().email = input() ?? ''
},
})
```
**Dynamic** — return `{ inputRequired }` conditionally from the handler:
```ts
app.step('get-email', {
handler: () => {
const { ctx, input } = useWfState()
const context = ctx()
const value = input()
if (!value) {
return { inputRequired: 'email' } // pause and ask for input
}
context.email = value
},
})
```
Both approaches cause the workflow to pause if no input was provided. The difference is that static `input` always pauses on first run, while dynamic `inputRequired` lets you decide at runtime.
## Running and Resuming
```ts
// Start the flow — it will pause at 'get-email'
let output = await app.start('registration', { email: '', name: '' })
console.log(output.finished) // false
console.log(output.inputRequired) // 'email'
```
The `output.state` object contains everything needed to resume later:
```ts
// Resume with the user's input
output = await app.resume(output.state, { input: 'user@example.com' })
console.log(output.finished) // true (or false if another step also needs input)
console.log(output.state.context) // { email: 'user@example.com', name: '' }
```
There's also a shortcut on the output object:
```ts
output = await output.resume('user@example.com')
```
## Multi-Step Input Collection
A flow can pause multiple times — once at each step that needs input:
```ts
app.step('ask-name', {
input: 'name',
handler: () => {
const { ctx, input } = useWfState()
ctx().name = input() ?? ''
},
})
app.step('ask-email', {
input: 'email',
handler: () => {
const { ctx, input } = useWfState()
ctx().email = input() ?? ''
},
})
app.step('ask-plan', {
input: 'plan',
handler: () => {
const { ctx, input } = useWfState()
ctx().plan = input() ?? 'free'
},
})
app.step('create-account', {
handler: () => {
// persist the account using the collected context
},
})
app.flow('signup', ['ask-name', 'ask-email', 'ask-plan', 'create-account'])
```
Running this flow:
```ts
let output = await app.start('signup', { name: '', email: '', plan: '' })
// paused at 'ask-name', inputRequired: 'name'
output = await output.resume('Alice')
// paused at 'ask-email', inputRequired: 'email'
output = await output.resume('alice@example.com')
// paused at 'ask-plan', inputRequired: 'plan'
output = await output.resume('premium')
// finished: true
// context: { name: 'Alice', email: 'alice@example.com', plan: 'premium' }
```
The input passed to a run is visible only to the **first** step executed in that run — the paused step that consumes it. Later steps in the same run see `undefined` from `input()`.
## Hardcoding Input in Flows
If you know the input value ahead of time, provide it in the flow schema to skip the pause.
Schema-hardcoded input is delivered to the handler's **second argument** (and to [string handlers](/wf/steps#string-handlers)) — it does **not** reach `useWfState().input()`, which only carries the run-level input passed to `start()` / `resume()`. Steps meant to accept hardcoded input should read the handler argument:
```ts
app.step('set-plan', {
input: 'plan',
handler: (ctx, input) => {
ctx.plan = (input as string) ?? 'free'
},
})
app.flow('auto-signup', [
{ id: 'set-plan', input: 'enterprise' },
'create-account',
])
// Runs to completion without pausing
const output = await app.start('auto-signup', { name: '', email: '', plan: '' })
console.log(output.finished) // true
console.log(output.state.context.plan) // 'enterprise'
```
## Rich Input Schemas
The `inputRequired` value can be anything — a string, an object, an array. Design it to match what your frontend or caller needs:
```ts
app.step('login', {
handler: () => {
const { input } = useWfState()
const credentials = input<{ username: string; password: string }>()
if (!credentials) {
return {
inputRequired: [
{ name: 'username', label: 'Username', type: 'text' },
{ name: 'password', label: 'Password', type: 'password' },
],
}
}
// process credentials...
},
})
```
The caller (e.g., a frontend) receives `output.inputRequired`, renders a form, and calls `resume()` with the collected data.
## Persisting State
`output.state` is a plain, serializable object. You can save it to a database and resume the workflow in a different process, on a different server, or days later:
```ts
// Save state when the workflow pauses
const output = await app.start('onboarding', initialContext)
if (!output.finished) {
await db.save('workflow:123', JSON.stringify(output.state))
}
// Later — restore and resume
const saved = JSON.parse(await db.get('workflow:123'))
const output = await app.resume(saved, { input: userInput })
```
The state object contains:
- `schemaId` — which flow is running
- `context` — the current context snapshot
- `indexes` — the exact position in the flow schema
This is everything the engine needs to pick up exactly where it left off.
## Retrying Failed Steps
When a step throws a `StepRetriableError`, the workflow pauses the same way — but instead of `inputRequired`, you get `error`:
```ts
const output = await app.start('pipeline', context)
if (!output.finished && output.error) {
console.log('Step failed:', output.error.message)
// Retry the same step (no input needed)
const retried = await output.retry()
// equivalent: await app.resume(output.state)
}
```
You can also provide new input when retrying (`output.retry(input)`), or add delay/backoff logic in your application code before retrying. See [Handling Retriable Errors](/wf/steps#handling-retriable-errors).
## Spies — Observing Execution
Attach a spy to observe step execution in real time. This is useful for logging, monitoring, or building progress indicators.
```ts
// Global spy — called for every workflow execution
app.attachSpy((event, ...args) => {
console.log(`[${event}]`, ...args)
})
// Per-execution spy — only for this specific run
const output = await app.start('my-flow', context, {
spy: (event, ...args) => {
if (event === 'step') {
console.log('Step executed:', args[0])
}
},
})
// Remove a global spy
app.detachSpy(spy)
```
Spy events let you track which steps ran, in what order, and with what results — without modifying your step handlers.
## Cleanup Hook
Pass `cleanup` to `start()` / `resume()` to run teardown when the execution ends — whether the flow finished, paused, or threw (the error is re-thrown after cleanup):
```ts
const output = await app.start('my-flow', context, {
cleanup: () => connection.release(),
})
```
---
URL: "wooks.moost.org/wf/introduction.html"
LLMS_URL: "wooks.moost.org/wf/introduction.md"
---
# What are Wooks Workflows?
`@wooksjs/event-wf` is a declarative workflow engine for Node.js. You describe **what** your workflow does — the steps, the order, the conditions — and the engine handles execution, pausing, resuming, and state management.
```ts
import { createWfApp, useWfState } from '@wooksjs/event-wf'
const app = createWfApp<{ approved: boolean; email: string }>()
app.step('validate', { handler: () => { /* validate the request */ } })
app.step('notify-success', { handler: () => { /* send approval email */ } })
app.step('notify-rejection', { handler: () => { /* send rejection email */ } })
app.step('review', {
input: 'approval', // pauses until input is provided
handler: () => {
const { ctx, input } = useWfState()
ctx<{ approved: boolean }>().approved = input() ?? false
},
})
app.flow('approval-process', [
'validate',
'review', // ← workflow pauses here
{ condition: 'approved', steps: ['notify-success'] },
{ condition: '!approved', steps: ['notify-rejection'] },
])
```
Workflows are **interruptible**. When a step needs input (from a user, an external API, a queue message), the workflow pauses and returns serializable state. You resume it later with the input — minutes, hours, or days later.
Read [Why Workflows](/wf/why) for the full motivation — the real-world problems that led to this design and why existing approaches fall short.
## Core Concepts
| Concept | What it is |
|---------|-----------|
| **Step** | A named function that does one thing. Steps can accept parameters via routing syntax (`add/:n`) and access shared context through composables. |
| **Flow** | A schema (array) that defines which steps run, in what order, with what conditions. Flows are data, not code. |
| **Context** | A typed object shared across all steps in a single workflow execution. Each execution gets its own isolated context. |
| **Input** | Data provided to a step at runtime. If a step requires input and none is available, the workflow pauses. |
## How It Fits in Wooks
Wooks is an event-processing framework with adapters for different event types: [HTTP requests](/webapp/), [CLI commands](/cliapp/), and **workflows**. All adapters share the same composable architecture — `useRouteParams()`, `useLogger()`, and other composables work identically across all of them.
`@wooksjs/event-wf` is built on top of [@prostojs/wf](https://github.com/prostojs/wf) and adds routing-based step resolution, async-scoped context isolation, and the composable API.
---
URL: "wooks.moost.org/wf/outlets.html"
LLMS_URL: "wooks.moost.org/wf/outlets.md"
---
# Outlets
Outlets let workflows **pause and deliver a request to the outside world** — render an HTTP form, send an email with a magic link, or dispatch to any custom delivery channel. When the user responds (submits the form, clicks the link), the workflow **resumes** automatically.
The outlet system handles state persistence, token generation, atomic `consume()` on every resume (as a short mutex against concurrent resumes), re-persisting under the **same handle** so the `wfs` token stays stable across the entire workflow run, and HTTP response building — so your step handlers stay declarative.
[[toc]]
## Overview
The flow looks like this:
1. A step returns `outletHttp(form)` or `outletEmail(to, template)` — the workflow pauses
2. The trigger function persists the state, generates a token, and dispatches to the outlet handler
3. The outlet handler delivers the response (HTTP body, email with magic link, etc.)
4. The client/user responds with the token + input
5. The trigger reads the token, restores state, and resumes the workflow
All of this is handled by `handleWfOutletRequest()` — a single function you wire to an HTTP endpoint.
## Quick Start
```ts
import { createHttpApp } from '@wooksjs/event-http'
import {
createWfApp,
useWfState,
createHttpOutlet,
createOutletHandler,
outletHttp,
HandleStateStrategy,
WfStateStoreMemory,
} from '@wooksjs/event-wf'
// 1. Create workflow app
const wf = createWfApp<{ email?: string; verified?: boolean }>()
// 2. Define steps
wf.step('ask-email', {
handler: () => {
const { ctx, input } = useWfState()
const data = input<{ email: string }>()
if (!data) return outletHttp({ fields: ['email'] }) // pause and ask client
ctx<{ email?: string }>().email = data.email // consume on resume
},
})
wf.flow('signup', ['ask-email'])
// 3. Wire outlet handler to HTTP
const http = createHttpApp()
const handle = createOutletHandler(wf)
http.post('/signup', () =>
handle({
state: new HandleStateStrategy({ store: new WfStateStoreMemory() }),
outlets: [createHttpOutlet()],
})
)
http.listen(3000)
```
**Client flow:**
```
POST /signup { wfid: "signup" }
← { fields: ["email"], wfs: "abc123" }
POST /signup { wfs: "abc123", input: { email: "user@example.com" } }
← { finished: true }
```
::: warning Input reaches only the paused step
The run input is visible only to the **first** step executed in a run (start or resume) — the paused step itself. Subsequent steps in the same run see `input()` as `undefined`, so the pausing step must consume and persist its own input (as `ask-email` does above).
:::
## Step Helpers
These helpers are re-exported from `@prostojs/wf/outlets` for convenience:
### `outletHttp(payload, context?)`
Pause the workflow and return a form/prompt to the HTTP client:
```ts
wf.step('login', {
handler: () => {
const { input } = useWfState()
if (input()) return
return outletHttp(
{ fields: ['username', 'password'] },
{ error: 'Invalid credentials' }, // optional context
)
},
})
```
### `outletEmail(target, template, context?)`
Pause and send an email (e.g. verification link, approval request):
```ts
wf.step('verify-email', {
handler: () => {
const { input } = useWfState()
if (input()) return
return outletEmail('user@example.com', 'verify-template', {
name: 'Alice',
})
},
})
```
### `outlet(name, data?)`
Generic outlet for custom delivery channels:
```ts
return outlet('sms', { payload: { phone: '+1234567890' } })
```
## Outlet Handlers
Outlet handlers implement the `WfOutlet` interface — they receive the pause request and a state token, and return the response.
### `createHttpOutlet(opts?)`
Built-in factory for HTTP outlets. Passes the step's payload through as the response body:
```ts
import { createHttpOutlet } from '@wooksjs/event-wf'
const httpOutlet = createHttpOutlet()
// With custom transform:
const httpOutlet = createHttpOutlet({
transform: (payload, context) => ({
type: 'form',
...payload,
...context,
}),
})
```
### `createEmailOutlet(sendFn)`
Built-in factory for email outlets. Delegates to your email-sending function:
```ts
import { createEmailOutlet } from '@wooksjs/event-wf'
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
await mailer.send({
to: target,
template,
data: {
...context,
verifyUrl: `https://example.com/signup?wfs=${token}`,
},
})
})
```
### Custom Outlets
Implement the `WfOutlet` interface directly:
```ts
import type { WfOutlet } from '@wooksjs/event-wf'
const smsOutlet: WfOutlet = {
name: 'sms',
tokenDelivery: 'out-of-band', // SMS recipient is not the HTTP caller
async deliver(request, token) {
await smsService.send(request.target!, `Your code: ${token}`)
return { response: { sent: true } }
},
}
```
#### `tokenDelivery`
Declares how the resumption token reaches the resumer. This is a **security-critical** field — get it wrong and the HTTP caller who triggered the pause will receive the token intended for a different principal.
- `'caller'` (default) — the HTTP caller IS the resumer. The trigger merges the token into the HTTP response body or `Set-Cookie` per `token.write`. Appropriate for multi-step HTTP forms.
- `'out-of-band'` — the outlet delivers the token through its own channel (email, SMS, Slack message, webhook). The HTTP caller is a bystander. The trigger suppresses body merge and cookie write so the caller receives no token.
Built-in outlets: `createHttpOutlet()` declares `'caller'`; `createEmailOutlet()` declares `'out-of-band'`. Any custom outlet whose resumer is a different principal than the HTTP caller MUST declare `'out-of-band'`.
## Configuration
The `handleWfOutletRequest` function (or the handler returned by `createOutletHandler`) accepts a `WfOutletTriggerConfig`:
```ts
interface WfOutletTriggerConfig {
/**
* State persistence strategy.
*
* - Single-strategy shortcut: pass a `WfStateStrategy` directly.
* - Named registry: pass `{ strategies, default }` to enable per-step
* `swapStrategy(name)` calls (see "Swapping strategies" below).
*
* The active strategy name is embedded in the issued token as a
* `.` prefix, so each strategy's storage is independent —
* resume picks the strategy from the token itself.
*
* Strategy names must match `/^[A-Za-z0-9_-]+$/`.
*/
state:
| WfStateStrategy
| {
strategies: Record
default: string | ((wfid: string) => string)
}
outlets: WfOutlet[]
token?: {
name?: string // default: 'wfs'
read?: Array<'body' | 'query' | 'cookie'> // default: ['body', 'query', 'cookie']
write?: 'body' | 'cookie' // default: 'body'
}
wfidName?: string // default: 'wfid'
allow?: string[]
block?: string[]
initialContext?: (body, wfid) => unknown
onFinished?: (ctx: { context, schemaId }) => unknown
}
```
### Swapping strategies
A workflow step can switch which strategy persists the **next** outlet pause by
calling `swapStrategy(name)`. The change is sticky for the rest of the
workflow because the new name travels with the token prefix.
```ts
import { swapStrategy, outletHttp } from '@wooksjs/event-wf'
app.step('escalate-storage', {
handler: (ctx) => {
if (ctx.payloadIsLarge) swapStrategy('kv') // escalate to durable storage
return outletHttp({ fields: ['decision'] })
},
})
```
The trigger config must use the named-registry form for `swapStrategy` to
resolve names:
```ts
{
strategies: {
enc: new EncapsulatedStateStrategy({ secret }),
kv: new HandleStateStrategy({ store }),
},
default: 'enc',
}
```
`swapStrategy()` validates only the name **format** (`/^[A-Za-z0-9_-]+$/` —
format violations throw at the call site). A well-formed name missing from the
registry errors loudly at the next pause instead: the trigger throws
`Workflow paused with unknown strategy '' …` — a config bug, surfaced as
a 500. Tokens whose prefix names an unknown strategy return HTTP 410 (same as
any invalid token — the trigger does not leak which strategies are registered).
When you drive `start()` / `resume()` directly (instead of through the outlet
trigger), set the initial strategy with the `strategy` run option and read the
post-swap name off the paused output — handy for offline resume drivers that
persist state under a strategy-specific keyspace:
```ts
const output = await wf.start('signup', ctx, { strategy: { name: 'enc' } })
if (output.inputRequired) {
const next = output.inputRequired.stateStrategy // post-swap name, e.g. 'kv'
// persist output.state under `next`'s keyspace, then later:
// await wf.resume(saved, { input, strategy: { name: next } })
}
```
The adapter carries only the strategy *name* — the strategy instances live in
your trigger registry (or your own resume driver), never in the workflow state.
### State Strategies
State strategies control how workflow state is persisted between pause and resume. **Choose based on whether the workflow is security-sensitive** — see the security note below.
**`HandleStateStrategy`** — server-side storage with a short opaque handle as token. Supports truly single-use tokens via atomic `getAndDelete` at the store layer. **Use this for any flow with real-world side effects** (auth, password reset, invite accept, financial operations).
```ts
import { HandleStateStrategy, WfStateStoreMemory } from '@wooksjs/event-wf'
const strategy = new HandleStateStrategy({
store: new WfStateStoreMemory(), // in-memory (dev/test only)
defaultTtl: 60_000, // 1 minute expiry
})
```
For production, implement the `WfStateStore` interface backed by Redis, a database, etc.
**`EncapsulatedStateStrategy`** — stateless, AES-256-GCM encrypted token. No server storage needed. The entire workflow state is encrypted into the token itself. **Use only for idempotent, non-sensitive flows** (multi-step forms, pure data collection).
```ts
import { EncapsulatedStateStrategy } from '@wooksjs/event-wf'
const strategy = new EncapsulatedStateStrategy({
secret: crypto.randomBytes(32), // 32-byte AES-256 key
defaultTtl: 300_000, // 5 minutes
})
```
#### Security note — token replay and session stability
The `wfs` token is a **workflow session credential**, not a per-step single-use token. It is minted on start, reused on every resume, and dies when the workflow finishes (or when an unexpected error burns the handle before the engine can re-persist).
What this means in practice:
- The same `wfs` survives browser refresh, bookmark-and-resume, magic-link reopen on a different device, and lost-connection-then-retry. The URL token stays valid across the entire workflow.
- With `HandleStateStrategy`, `consume()` still runs atomically on every resume — but only as a short mutex against simultaneous resumes. Two concurrent tabs calling the same `wfs`: one wins, the other gets 410. After the winner re-persists under the same handle, the loser's *next* attempt succeeds (the token is alive again).
- With `EncapsulatedStateStrategy`, the token IS the state; `consume()` is a stateless no-op, and a copy of the token remains valid for the full TTL. The strategy cannot enforce any kind of single-use semantics. The token also changes on every persist because the ciphertext is a function of the (now-advanced) state — so `EncapsulatedStateStrategy` does effectively rotate the token even though the engine asks it to reuse the handle.
Replay protection on a leaked `HandleStateStrategy` token: the workflow record advances after each step, so a replayed token resumes from wherever the workflow currently is — there is no way to re-execute a previous step. An attacker who can intercept the token in transit can also intercept anything that would have rotated it, so transport-level rotation never provided meaningful protection against that threat model. Steps with non-idempotent side effects should guard themselves at the step layer (idempotency keys in the form payload, advance counters, etc.); do not rely on token rotation for replay safety.
### Resume Semantics
On every resume the trigger calls `strategy.consume()` atomically before running the step handler. After the step returns, the engine re-persists under the **same** handle so the URL `wfs` stays live for the duration of the workflow.
A replay that loses the race against a concurrent resume — or a request after the workflow has finished — responds with HTTP **410 Gone** and body `{ error: 'Invalid or expired workflow state' }`. With `EncapsulatedStateStrategy`, `consume()` is a stateless no-op (see the security note above) and the token remains replayable until TTL.
### Error Status Codes
The trigger sets the HTTP status via `useResponse().setStatus(...)` — the body always carries `{ error: '...' }` only:
| Branch | HTTP status |
| ------------------------------------- | ----------- |
| Expired / invalid resume token | 410 Gone |
| `wfid` not in `allow` list | 403 |
| `wfid` in `block` list | 403 |
| Missing both `wfs` and `wfid` | 400 |
| Unknown outlet name returned by step | 500 |
On pause — including a re-pause at the same step for validation retry, and on advance to the next paused step — the trigger re-persists state under the **same** handle (with `HandleStateStrategy`). The `wfs` returned in the response equals the one in the request, so a step handler that validates input and decides to re-prompt via `outletHttp(form, { error: 'invalid' })` returns the unchanged token, and the caller (or a refresh) keeps using it.
`EncapsulatedStateStrategy` is the exception: the token IS the encrypted state, so the returned `wfs` differs from the one in the request whenever the state changes. The client should always read the token from the response body or cookie on this strategy.
**Fail-closed on unexpected errors.** A consumed handle is restored only after the step returns. If the step throws unexpectedly, the engine never reaches the re-persist call — the handle is gone and the user must restart the workflow. This is the security-preferred behavior (no lingering replayable token after a failed attempt). Handle expected validation failures by returning an outlet signal from the step handler (the engine re-persists under the same handle on the re-pause), NOT by throwing.
### Token Configuration
**`token.read`** — where to look for the state token in incoming requests. Checked in order:
```ts
{ token: { read: ['body', 'query', 'cookie'] } } // default
{ token: { read: ['cookie'] } } // cookie-only
```
**`token.write`** — how to return the token to the client:
- `'body'` (default) — merges the token into the JSON response body
- `'cookie'` — sets an httpOnly cookie
### Access Control
```ts
{
allow: ['signup', 'reset-password'], // only these workflows can be started
block: ['admin-setup'], // these are always blocked
}
```
### Initial Context
Seed the workflow context from the request body when starting:
```ts
{
initialContext: (body, wfid) => ({
source: 'web',
locale: body?.locale ?? 'en',
}),
}
```
### Completion Handler
Control the HTTP response when a workflow finishes, without coupling steps to HTTP:
```ts
{
onFinished: ({ context, schemaId }) => ({
success: true,
result: context,
}),
}
```
If not provided, the trigger checks `useWfFinished()` (set from within steps) and falls back to `{ finished: true }`.
## Composables
### `useWfFinished()`
Set the HTTP response for when the workflow completes. Call from the last step:
```ts
import { useWfFinished } from '@wooksjs/event-wf'
wf.step('complete', {
handler: () => {
useWfFinished().set({ type: 'redirect', value: '/dashboard' })
// or
useWfFinished().set({ type: 'data', value: { success: true }, status: 200 })
},
})
```
This is an alternative to `onFinished` in the config — use it when different steps need different completion responses.
### `useWfOutlet()`
Advanced composable for inspecting outlet infrastructure from within steps:
```ts
import { useWfOutlet } from '@wooksjs/event-wf'
wf.step('custom-step', {
handler: () => {
const { getOutlet } = useWfOutlet()
const httpOutlet = getOutlet('http')
// ...
},
})
```
## Full Example: Signup with Email Verification
```ts
import { createHttpApp } from '@wooksjs/event-http'
import {
createWfApp,
useWfState,
useWfFinished,
createHttpOutlet,
createEmailOutlet,
createOutletHandler,
outletHttp,
outletEmail,
HandleStateStrategy,
WfStateStoreMemory,
} from '@wooksjs/event-wf'
interface SignupContext {
email?: string
verificationSent?: boolean
verified?: boolean
}
const wf = createWfApp()
wf.step('collect-email', {
handler: () => {
const { ctx, input } = useWfState()
const data = input<{ email: string }>()
if (!data) return outletHttp({ fields: ['email'], title: 'Enter your email' })
ctx().email = data.email // consume the input here — later steps won't see it
},
})
wf.step('send-verification', {
handler: () => {
const { ctx } = useWfState()
const context = ctx()
if (context.verificationSent) return // link clicked — continue to 'complete'
context.verificationSent = true // persisted with the paused state
return outletEmail(context.email!, 'verify-email')
},
})
wf.step('complete', {
handler: () => {
const { ctx } = useWfState()
ctx().verified = true
useWfFinished().set({ type: 'redirect', value: '/welcome' })
},
})
wf.flow('signup', ['collect-email', 'send-verification', 'complete'])
const http = createHttpApp()
const store = new WfStateStoreMemory()
const handle = createOutletHandler(wf)
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
console.log(`Send ${template} to ${target} with link: /signup?wfs=${token}`)
})
http.post('/signup', () =>
handle({
state: new HandleStateStrategy({ store }),
outlets: [createHttpOutlet(), emailOutlet],
})
)
// Also handle GET for email link clicks
http.get('/signup', () =>
handle({
state: new HandleStateStrategy({ store }),
outlets: [createHttpOutlet(), emailOutlet],
token: { read: ['query'] },
})
)
http.listen(3000)
```
**Flow:**
1. `POST /signup { wfid: "signup" }` → returns form fields
2. `POST /signup { wfs: "token", input: { email: "user@test.com" } }` → `collect-email` saves the email, `send-verification` sets the flag and sends the email
3. User clicks `GET /signup?wfs=token` → `send-verification` re-runs, sees the flag, and continues — workflow completes, redirect to `/welcome`
The link click is a `GET` with no body, so no input reaches the workflow — that's why `send-verification` uses a context flag (persisted with the paused state) rather than input to detect the resume.
---
URL: "wooks.moost.org/wf/steps.html"
LLMS_URL: "wooks.moost.org/wf/steps.md"
---
# Steps
A step is a named, reusable unit of work. You define steps once and reference them by id inside [flows](/wf/flows).
[[toc]]
## Defining a Step
```ts
app.step('step-id', {
handler: (ctx) => {
// your logic here
},
})
```
The `handler` receives the workflow context as its first argument. You can mutate it directly:
```ts
app.step('increment', {
handler: (ctx) => {
ctx.counter++
},
})
```
## Step IDs Must Be Unique
Steps are registered on a router, and **the first registration of an id wins** — registering the same id again is ignored and the later handler is never reached. A duplicate logs a warning so the mistake is visible instead of silent:
```
WF step "increment" registered more than once — the first registration wins.
```
By default the router is a **process-global singleton**, shared across every workflow app created without an explicit `Wooks` instance. So ids can collide _across apps_, not just within one — this most often bites tests that build a fresh app per case and reuse an id.
### Fail loudly with `strictStepIds`
Pass `strictStepIds: true` to turn a duplicate id into a thrown error instead of a warning — handy in CI where a collision should fail the build:
```ts
const app = createWfApp({ strictStepIds: true })
app.step('process', { handler: () => {} })
app.step('process', { handler: () => {} })
// throws: WF step "process" already registered. Step ids must be unique (strictStepIds enabled).
```
### Resetting the router between tests
Because the router is shared, reset it before each test so ids need not be globally unique and each test stays hermetic:
```ts
import { clearGlobalWooks } from 'wooks'
beforeEach(() => clearGlobalWooks())
```
After the reset every test gets a fresh router, so re-registering the same ids across cases is fine. The same `clearGlobalWooks()` call is what HMR / dev-restart flows use to re-register steps cleanly.
## Accessing State with `useWfState`
Inside any step handler, call `useWfState()` to access the full workflow execution state:
```ts
import { useWfState } from '@wooksjs/event-wf'
app.step('process', {
handler: () => {
const { ctx, input, schemaId, stepId, indexes, resume } = useWfState()
const context = ctx() // typed workflow context
const stepInput = input() // input provided for this step (if any)
const flowId = schemaId // id of the running flow
const currentStep = stepId() // normalized step id, e.g. '/add/5' (null before the first step)
const position = indexes() // position in the flow schema
const isResumed = resume // true if this is a resumed execution
},
})
```
`useWfState()` works from anywhere in the call stack — it uses `AsyncLocalStorage` under the hood, so you can call it from utility functions, not just directly in the handler.
## Parametric Steps
Step ids support route-style parameters. This lets you create generic steps that receive values through their id.
```ts
import { useRouteParams } from '@wooksjs/event-core'
app.step('add/:n', {
handler: (ctx) => {
const n = Number(useRouteParams().get('n'))
ctx.result += n
},
})
```
Now you can call this step with different values in your flow:
```ts
app.flow('calculate', ['add/5', 'add/10', 'add/3'])
```
### Supported Routing Patterns
Step ids use [@prostojs/router](https://github.com/prostojs/router) syntax:
| Pattern | Example | Matches |
|---------|---------|---------|
| Static | `validate` | Exactly `validate` |
| Named parameter | `add/:n` | `add/5`, `add/100` |
| Multiple parameters | `move/:from/:to` | `move/inbox/archive` |
| Optional parameter | `log/:level?` | `log` and `log/debug` |
| Wildcard | `notify/*` | `notify/email`, `notify/slack/general` |
Access parameters with `useRouteParams().get('paramName')` — use `get('*')` for wildcard captures.
## String Handlers
For lightweight, serializable steps, you can use a JavaScript expression string instead of a function:
```ts
app.step('add', {
input: 'number',
handler: 'ctx.result += input',
})
app.step('double', {
handler: 'ctx.result *= 2',
})
```
String handlers run in a restricted sandbox with only `ctx` (the workflow context), `input` (the step input), and `StepRetriableError` available. They cannot access Node.js APIs, imports, or composables.
A string handler signals a retriable failure by **returning** (not throwing) a `StepRetriableError`:
```ts
app.step('check-balance', {
handler: 'ctx.balance < 0 ? new StepRetriableError(new Error("negative balance")) : undefined',
})
```
Use string handlers when you need steps to be **serializable** (e.g., stored in a database or sent over the wire). Use function handlers for everything else.
## Step with Required Input
A step can declare that it requires input. If the input is not provided when the step runs, the workflow **pauses** and waits for it.
```ts
app.step('get-approval', {
input: 'boolean', // declares that this step needs input
handler: (ctx) => {
const { input } = useWfState()
ctx.approved = input() ?? false
},
})
```
See [Input & Resume](/wf/input-and-resume) for the full pause/resume pattern.
## Handling Retriable Errors
If a step fails but can be retried, throw a `StepRetriableError`:
```ts
import { StepRetriableError } from '@wooksjs/event-wf'
app.step('call-api', {
handler: async (ctx) => {
const res = await fetch(ctx.apiUrl)
if (!res.ok) {
throw new StepRetriableError(new Error(`API returned ${res.status}`))
}
ctx.data = await res.json()
},
})
```
The workflow pauses with the error available on the output. You can retry by resuming:
```ts
const output = await app.start('my-flow', initialContext)
if (!output.finished && output.error) {
console.log(output.error.message) // "API returned 503"
// retry the failed step:
const retried = await output.retry()
// equivalent: await app.resume(output.state)
}
```
Regular (non-retriable) errors propagate normally and are thrown from `start()` / `resume()`.
---
URL: "wooks.moost.org/wf/why.html"
LLMS_URL: "wooks.moost.org/wf/why.md"
---
# Why Workflows?
## The Problem
Consider a login flow. At first glance it seems simple — the user enters credentials, you verify them, done. But real login flows branch:
```
login → MFA → done
login → forgot password → email sent → resume from email → new password → MFA? → done
login → password expired → new password → MFA → done
```
Now try to implement this with plain code. You'll end up with something like:
```ts
if (needsMfa && !mfaCompleted) { ... }
if (passwordExpired && !newPasswordSet) { ... }
if (forgotPassword && emailSent && !resumedFromEmail) { ... }
if (forgotPassword && resumedFromEmail && newPasswordSet && needsMfa) { ... }
```
Every new branch adds more boolean flags. The flags interact in ways that are hard to reason about. Six months later, nobody remembers what combination of `passwordExpired + mfaCompleted + resumedFromEmail` actually means or which edge cases are covered.
This isn't unique to login flows. The same problem appears in:
- **Onboarding wizards** — different steps depending on user type, plan, or region
- **Checkout processes** — shipping, billing, promo codes, each with their own validation and fallback paths
- **Approval chains** — requests that bounce between reviewers, require revisions, escalate, or time out
- **Setup wizards** — conditional configuration steps where earlier choices determine later options
The common thread: **multi-step processes with conditional paths and external input**. As these grow, ad-hoc state management becomes the bottleneck — not the business logic itself, but keeping track of *where you are* and *what should happen next*.
## What Goes Wrong Without a Framework
**State becomes implicit.** Instead of a clear "you are on step 3 of 5", state is scattered across flags, session variables, and conditional checks. Debugging means reverse-engineering which combination of booleans led to the current situation.
**Adding a branch means touching everything.** A new requirement like "add phone verification after MFA for high-risk logins" means weaving new conditions into existing `if/else` chains and hoping nothing breaks.
**Pause and resume is DIY.** When a step waits for user input (email confirmation, file upload, external approval), you need to serialize state, store it somewhere, and reconstruct it on the next request — all by hand, differently every time.
**Flows aren't visible.** The actual sequence of steps exists only in the developer's head and in tangled control flow. There's no single place where you can see "this is what happens when a user logs in with an expired password."
## How Workflows Solve This
A workflow engine gives you a structured way to define steps and the order they run in. Instead of flags, you have a **schema**:
```ts
app.flow('login', [
'authenticate',
{ condition: 'passwordExpired', steps: ['set-new-password'] },
{ condition: 'mfaEnabled', steps: ['verify-mfa'] },
'complete-login',
])
app.flow('forgot-password', [
'send-reset-email',
'await-email-confirmation', // pauses here
'set-new-password',
{ condition: 'mfaEnabled', steps: ['verify-mfa'] },
'complete-login',
])
```
The flow *is* the documentation. You can read it top-to-bottom and understand every path.
**State is managed for you.** The engine tracks which step you're on, holds the shared context, and knows how to pause and resume. You don't manage flags — you define steps and conditions.
**Adding a branch is local.** Need phone verification? Add a step and a condition. The rest of the flow doesn't change.
**Pause and resume is built in.** When a step needs input, the engine pauses and gives you a serializable state object. Store it however you want. Resume whenever the input arrives.
## Why String Handlers Matter
Most workflow engines require handlers to be functions defined in code. This works for static workflows, but what if you need to:
- Let non-developers configure workflow logic through an admin UI
- Store complete workflow definitions in a database
- Modify workflow behavior at runtime without redeploying
Wooks Workflows supports **string handlers** — JavaScript expressions evaluated at runtime:
```ts
app.step('apply-discount', {
handler: 'ctx.total -= ctx.total * 0.1',
})
```
String handlers are fully serializable. You can load them from a database, build them from user input, or generate them dynamically. Combined with serializable flow schemas (which are just arrays of objects), entire workflows can be stored, versioned, and modified without touching application code.
Function handlers remain the default for complex logic, imports, and composable access. String handlers are an option for when portability and runtime configuration matter.
---
URL: "wooks.moost.org/wooks"
LLMS_URL: "wooks.moost.org/wooks.md"
---
# Wooks Flavors
Wooks is event-agnostic by design. The core — `EventContext`, `defineWook`, `key`, `cached` — works the same regardless of what triggered the event. On top of this core, Wooks provides **adapters** (flavors) for specific event domains.
## HTTP
**Package:** `@wooksjs/event-http`
Build Node.js HTTP servers where every handler is a plain function that returns its response. Request data is available through wooks — on demand, typed, cached.
```ts
import { createHttpApp } from '@wooksjs/event-http'
import { useBody } from '@wooksjs/http-body'
const app = createHttpApp()
app.post('/users', async () => {
const { parseBody } = useBody()
const user = await parseBody<{ name: string }>()
return { created: user.name }
})
app.listen(3000)
```
Available wooks: `useRequest()`, `useResponse()`, `useBody()`, `useCookies()`, `useUrlParams()`, `useAuthorization()`, and more. Plus `@wooksjs/http-static` for file serving and `@wooksjs/http-proxy` for reverse proxy.
[Get started with HTTP →](/webapp/) [See benchmarks →](/benchmarks/wooks-http)
## WebSocket
**Package:** `@wooksjs/event-ws` + `@wooksjs/ws-client`
Build real-time WebSocket servers with routed message handlers and composable state. Pair with the zero-dependency client for structured RPC, fire-and-forget messaging, rooms, and automatic reconnection.
```ts
import { createHttpApp } from '@wooksjs/event-http'
import { createWsApp, useWsMessage, useWsRooms } from '@wooksjs/event-ws'
const http = createHttpApp()
const ws = createWsApp(http)
http.upgrade('/ws', () => ws.upgrade())
ws.onMessage('message', '/chat/:room', () => {
const { data } = useWsMessage<{ text: string }>()
const { broadcast } = useWsRooms()
broadcast('message', data)
})
http.listen(3000)
```
Available wooks: `useWsConnection()`, `useWsMessage()`, `useWsRooms()`, `useWsServer()`. HTTP composables (`useHeaders()`, `useCookies()`, etc.) work transparently via the upgrade request context.
[Get started with WebSocket →](/wsapp/)
## CLI
**Package:** `@wooksjs/event-cli`
Build command-line applications with routed commands, typed options, and auto-generated help — using the same wook patterns as HTTP.
```ts
import { createCliApp, useCliOption, useRouteParams } from '@wooksjs/event-cli'
const app = createCliApp()
app.cli('deploy :env', () => {
const { get } = useRouteParams<{ env: string }>()
const verbose = useCliOption('verbose')
return `Deploying to ${get('env')}...`
})
app.run()
```
Commands are registered with route-style patterns (`deploy/:env`). Options are parsed automatically. Help output is generated from command metadata via [@prostojs/cli-help](https://github.com/prostojs/cli-help).
[Get started with CLI →](/cliapp/)
## Workflows
**Package:** `@wooksjs/event-wf`
A declarative workflow engine for multi-step pipelines. Define steps and flows as data, and the engine handles execution, pausing, resuming, and state management.
```ts
import { createWfApp, useWfState } from '@wooksjs/event-wf'
const app = createWfApp<{ approved: boolean }>()
app.step('validate', { handler: () => { /* ... */ } })
app.step('notify-success', { handler: () => { /* ... */ } })
app.step('notify-rejection', { handler: () => { /* ... */ } })
app.step('review', {
input: 'approval',
handler: () => {
const { ctx, input } = useWfState()
ctx<{ approved: boolean }>().approved = input() ?? false
},
})
// Steps must be registered before the flows that reference them
app.flow('approval-process', [
'validate',
'review',
{ condition: 'approved', steps: ['notify-success'] },
{ condition: '!approved', steps: ['notify-rejection'] },
])
```
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/)
## Custom Adapters
You can build your own adapter for any event-driven scenario — job queues, message brokers, custom protocols. All adapters share the same `EventContext`, the same `defineWook`, the same primitives.
[Create a custom adapter →](/wooks/advanced/wooks-adapter)
## In This Section
- [What is Wooks?](/wooks/what) — the core idea: wooks, typed event context, and the package map
- [Why Wooks?](/wooks/why) — motivation, design decisions, and framework comparison
- [Generic Wooks](/wooks/generic-wooks) — `useEventId()`, `useLogger()`, `useRouteParams()`: wooks that work in every flavor
- [Type Safety](/wooks/type-safety) — how compile-time typing flows through every primitive
- [Event Context](/wooks/advanced/wooks-context) — the `EventContext` API and `event-core` primitives in depth
- [Custom Event Context](/wooks/advanced/custom-context) — step-by-step example of a custom event kind
- [Custom Adapter](/wooks/advanced/wooks-adapter) — build your own adapter on `WooksAdapterBase`
- [Logging](/wooks/advanced/logging) — configure the logger and use `useLogger()`
---
URL: "wooks.moost.org/wooks/advanced/custom-context.html"
LLMS_URL: "wooks.moost.org/wooks/advanced/custom-context.md"
---
# Creating a Custom Event Context
Let's walk through a step-by-step example of creating a custom event context for a fictional "JOB" event. We'll show how to:
1. Define an event kind with typed slots
2. Create the event context and run handlers inside it
3. Build wooks using `defineWook`, `key`, and `cached`
This example will help you understand how to build your own event types on top of `@wooksjs/event-core`.
## 1. Define the Event Kind
First, declare the shape of the JOB event using `defineEventKind`. Each `slot` becomes a typed key that must be seeded when the context is created.
```ts
import { defineEventKind, slot } from '@wooksjs/event-core'
const jobKind = defineEventKind('JOB', {
jobId: slot(),
input: slot(),
})
```
This gives us `jobKind.keys.jobId` and `jobKind.keys.input` — typed accessors for reading these values from any context.
## 2. Create Context Functions
Next, create a function that initializes a JOB event context and runs a callback inside it.
```ts
import { createEventContext, current } from '@wooksjs/event-core'
import type { EventContextOptions } from '@wooksjs/event-core'
function runJob(
data: { jobId: string; input: unknown },
options: EventContextOptions,
fn: () => R,
): R {
return createEventContext(options, jobKind, data, fn)
}
```
Inside the callback passed to `runJob`, all wooks and `current()` calls will have access to the JOB context.
## 3. Build Wooks
With the event kind defined, we can build wooks that read and write job-scoped data.
### Managing Job Status
Use `key` for mutable state that changes during event processing:
```ts
import { key, current, defineWook } from '@wooksjs/event-core'
const jobStatusKey = key<'pending' | 'running' | 'completed'>('job.status')
export const useJobStatus = defineWook((ctx) => ({
getStatus: () => (ctx.has(jobStatusKey) ? ctx.get(jobStatusKey) : 'pending'),
setStatus: (status: 'pending' | 'running' | 'completed') => {
ctx.set(jobStatusKey, status)
},
}))
```
### Storing Job Results
```ts
const jobResultKey = key('job.result')
export const useJobResult = defineWook((ctx) => ({
getResult: () => (ctx.has(jobResultKey) ? ctx.get(jobResultKey) : undefined),
setResult: (result: unknown) => {
ctx.set(jobResultKey, result)
},
}))
```
### Lazy Computed Data
Use `cached` for values that are derived once from other context data:
```ts
import { cached } from '@wooksjs/event-core'
const jobSummary = cached((ctx) => {
const jobId = ctx.get(jobKind.keys.jobId)
const input = ctx.get(jobKind.keys.input)
return `Job ${jobId}: ${JSON.stringify(input)}`
})
export const useJobSummary = defineWook((ctx) => ({
getSummary: () => ctx.get(jobSummary),
}))
```
## Bringing It All Together
```ts
const logger = { info: console.log, warn: console.warn, error: console.error, debug: console.debug }
runJob({ jobId: 'abc123', input: { foo: 'bar' } }, { logger }, () => {
const { getStatus, setStatus } = useJobStatus()
const { setResult } = useJobResult()
const { getSummary } = useJobSummary()
console.log(getStatus()) // 'pending'
console.log(getSummary()) // 'Job abc123: {"foo":"bar"}'
setStatus('running')
console.log(getStatus()) // 'running'
// After processing:
setResult({ success: true })
setStatus('completed')
})
```
Here we've demonstrated:
- **Declaring an event kind:** `defineEventKind` with `slot()` markers defines the typed seed shape.
- **Creating a context:** `createEventContext` seeds the slots and runs the callback.
- **Using wooks:** `defineWook` creates cached wooks; `key` stores mutable state; `cached` derives computed values.
This pattern applies to any custom event type in your application.
---
URL: "wooks.moost.org/wooks/advanced/logging.html"
LLMS_URL: "wooks.moost.org/wooks/advanced/logging.md"
---
# Logging in Wooks
Wooks integrates with [`@prostojs/logger`](https://github.com/prostojs/logger) to provide a flexible and composable logging solution. Every event context has access to a `Logger` instance that supports standard log methods and configurable transports. By default, logs are written to the console with colorized output, but you can configure the logger to your exact requirements.
## Configuring the Logger
You can pass a configured logger instance when creating the Wooks application. The `logger` option accepts any `TConsoleBase`-compatible logger — an object with `error`, `warn`, `log`, `info`, `debug`, and `trace` methods. `ProstoLogger` is the recommended implementation; it lets you configure:
- **Logging Level:** Determines which log messages are displayed (e.g., `fatal`, `error`, `warn`, `log`, `info`, `debug`, `trace`).
- **Transports:** Functions that handle the output of log messages — write to the console, files, external services, etc. Without at least one transport, a `ProstoLogger` emits nothing.
- **Topic:** The constructor's second argument — a label attached to the logger's messages.
**Example (HTTP app):**
```ts
import { createHttpApp } from '@wooksjs/event-http'
import { ProstoLogger } from '@prostojs/logger'
const app = createHttpApp({
logger: new ProstoLogger(
{
level: 1, // Only fatal and error logs will show
transports: [(log) => console.log(`[${log.topic}][${log.type}] ${log.timestamp}`, ...log.messages)],
},
'my-app', // topic
),
})
```
**Example (CLI app):**
```ts
import { createCliApp } from '@wooksjs/event-cli'
import { coloredConsole, createConsoleTransort, ProstoLogger } from '@prostojs/logger'
const app = createCliApp({
logger: new ProstoLogger(
{
level: 4, // Show up to info level
transports: [createConsoleTransort({ format: coloredConsole })],
},
'my-cli', // topic
),
})
```
The `logger` option works the same across all Wooks adapters.
## Accessing the Logger
### Global Logger
You can retrieve a globally scoped logger from any Wooks application instance:
```ts
const myLogger = app.getLogger('[my-custom-topic]')
myLogger.log('This is a log message')
myLogger.error('This is an error message')
```
This global logger does not attach event-specific data, but inherits all global configurations, levels, and transports defined when creating the app.
### Event Logger
Inside any event handler, you can access a context-aware logger tied to the current event:
```ts
import { useLogger } from '@wooksjs/event-core'
// Works in HTTP handlers, CLI handlers, workflow steps — any event type
const logger = useLogger()
logger.debug('debug message')
logger.info('info message')
logger.error('error message')
```
### Topic-Scoped Logger
You can pass a topic string to `useLogger()` to create a child logger scoped to a specific area. The child logger inherits all configuration (level, transports) from the parent and prefixes its output with the topic:
```ts
import { useLogger } from '@wooksjs/event-core'
const dbLogger = useLogger('db')
dbLogger.info('Connection established')
const authLogger = useLogger('auth')
authLogger.warn('Token expired')
```
Under the hood this calls `logger.createTopic(topic)` on the context's logger. If the logger does not support `createTopic` (e.g. a plain console-style logger), the base logger is returned unchanged.
**Key points:**
- `useLogger()` returns a `Logger` instance associated with the current event context.
- `useLogger('topic')` returns a child logger scoped to that topic.
- You can also import `useLogger` from `'wooks'` — it is re-exported for convenience.
## Logging Levels and Methods
`@prostojs/logger` supports multiple log levels, each corresponding to a method on the logger:
- `fatal(message: unknown, ...args: unknown[])`
- `error(message: unknown, ...args: unknown[])`
- `warn(message: unknown, ...args: unknown[])`
- `log(message: unknown, ...args: unknown[])`
- `info(message: unknown, ...args: unknown[])`
- `debug(message: unknown, ...args: unknown[])`
- `trace(message: unknown, ...args: unknown[])`
The `level` number controls which of these methods produce output — it is inclusive: messages with a level less than or equal to `level` are emitted. For instance, a level of `1` allows `fatal` (0) and `error` (1) logs only, while a level of `2` additionally allows `warn`.
## Summary
Wooks provides a convenient, integrated logging system through `@wooksjs/event-core` and `@prostojs/logger`. By configuring the logger globally, you gain control over which messages appear, where they're sent, and how they're formatted. The `useLogger()` wook gives you access to the event-scoped logger, while `app.getLogger()` provides a topic-scoped logger for application-level logging.
---
URL: "wooks.moost.org/wooks/advanced/wooks-adapter.html"
LLMS_URL: "wooks.moost.org/wooks/advanced/wooks-adapter.md"
---
# Creating a Custom Wooks Adapter
Wooks handles various event types beyond just HTTP. You can create custom adapters that provide a familiar developer experience for any event-driven scenario — such as workflows, jobs, or specialized protocols.
## Overview
To create a custom adapter:
1. **Define an Event Kind:**
Declare the event's typed slots using `defineEventKind` and `slot`.
*(See [Custom Event Context](/wooks/advanced/custom-context) for patterns and examples.)*
2. **Build Wooks:**
Implement wooks using `defineWook`, `key`, and `cached` to provide access to event-scoped data.
*(See [Custom Event Context](/wooks/advanced/custom-context#_3-build-wooks) for examples.)*
3. **Create a Context Factory:**
Build a function that creates an `EventContext`, seeds it with event-specific data, and returns a runner function.
4. **Extend `WooksAdapterBase`:**
Build a class that registers event handlers via the router and triggers events using the context factory.
## Step by Step
### 1. Define Event Kind and Wooks
```ts
import {
defineEventKind,
slot,
key,
defineWook,
} from '@wooksjs/event-core'
// Define the event kind with typed seed slots
const jobKind = defineEventKind('JOB', {
jobId: slot(),
payload: slot(),
})
// Build wooks for this event type
const jobStatusKey = key<'pending' | 'running' | 'done'>('job.status')
export const useJob = defineWook((ctx) => ({
getJobId: () => ctx.get(jobKind.keys.jobId),
getPayload: () => ctx.get(jobKind.keys.payload) as T,
getStatus: () => ctx.has(jobStatusKey) ? ctx.get(jobStatusKey) : 'pending',
setStatus: (s: 'pending' | 'running' | 'done') => ctx.set(jobStatusKey, s),
}))
```
### 2. Create a Context Factory
Every built-in adapter exports a **context factory** that hardcodes the event kind and delegates to `createEventContext`. The factory signature matches `createEventContext(options, kind, seeds, fn)` but omits the `kind` parameter:
```ts
import { createEventContext } from '@wooksjs/event-core'
import type { EventContextOptions, EventKindSeeds } from '@wooksjs/event-core'
export function createJobContext(
options: EventContextOptions,
seeds: EventKindSeeds,
fn: () => R,
): R {
return createEventContext(options, jobKind, seeds, fn)
}
```
This is the pattern used by all built-in adapters: `createHttpContext`, `createCliContext`, `createWsConnectionContext`, `createWsMessageContext`, `createWfContext`, and `resumeWfContext`. The context factory:
- Accepts `EventContextOptions` (with optional `parent` for nested contexts)
- Accepts typed `seeds` matching the event kind schema
- Runs `fn` inside the seeded `AsyncLocalStorage` context
- Returns `fn`'s return value (sync or async) for span tracking
### 3. Extend `WooksAdapterBase`
```ts
import { WooksAdapterBase, Wooks } from 'wooks'
import type { TWooksHandler } from 'wooks'
import type { TConsoleBase } from '@prostojs/logger'
interface TJobAdapterOptions {
logger?: TConsoleBase
onNotFound?: TWooksHandler
}
class WooksJob extends WooksAdapterBase {
protected logger: TConsoleBase
protected eventContextOptions: EventContextOptions
constructor(opts?: TJobAdapterOptions, wooks?: Wooks | WooksAdapterBase) {
super(wooks, opts?.logger)
this.logger = opts?.logger || this.getLogger('[wooks-job]')
this.eventContextOptions = this.getEventContextOptions()
}
/** Register a handler for a job route. */
job(path: string, handler: TWooksHandler) {
return this.on('JOB', path, handler)
}
/** Trigger a job event. */
async trigger(path: string, payload: unknown) {
return createJobContext(
this.eventContextOptions,
{ jobId: path, payload },
async () => {
const { handlers } = this.wooks.lookup('JOB', `/${path}`)
if (!handlers) {
throw new Error(`No handler for job: ${path}`)
}
for (const handler of handlers) {
return await handler()
}
},
)
}
}
```
Key points:
- Call `super(wooks, opts?.logger)` — accepts an optional shared `Wooks` instance or another adapter
- Cache `this.getEventContextOptions()` once in the constructor
- Register handlers with `this.on('JOB', path, handler)` — the first argument is the event method
- In the trigger method, use the context factory and look up handlers via `this.wooks.lookup()`
### Usage
```ts
const app = new WooksJob()
app.job('/process', async () => {
const { getJobId, getPayload, setStatus } = useJob()
setStatus('running')
const data = getPayload<{ items: string[] }>()
console.log(`Job ${getJobId()}: processing ${data.items.length} items`)
setStatus('done')
return { processed: data.items.length }
})
await app.trigger('process', { items: ['a', 'b', 'c'] })
```
## Sharing Context Across Adapters
Adapters can share a single `Wooks` router by passing one adapter into another's constructor:
```ts
import { createHttpApp } from '@wooksjs/event-http'
const http = createHttpApp()
const jobs = new WooksJob({}, http) // shares the same Wooks instance
// HTTP handler that triggers a job
http.post('/submit', async () => {
return await jobs.trigger('process', { items: ['a', 'b'] })
})
```
## The Wooks Router Instance
All adapters delegate routing to a shared `Wooks` instance — a thin wrapper around [`@prostojs/router`](https://github.com/prostojs/router).
### Global Singleton
When the `wooks` constructor argument is omitted, `WooksAdapterBase` calls `getGlobalWooks()` — a process-global singleton created on first use. Every adapter created without an explicit instance shares this router. The optional `logger` and `routerOpts` arguments only take effect on the call that creates the singleton; later calls return the existing instance unchanged. Use `clearGlobalWooks()` to reset it (useful in tests or dev-mode hot reload):
```ts
import { getGlobalWooks, clearGlobalWooks } from 'wooks'
const wooks = getGlobalWooks() // creates on first call, reuses afterwards
clearGlobalWooks() // next getGlobalWooks() creates a fresh instance
```
### Router Options
To control routing behavior, construct a `Wooks` instance explicitly with `TWooksOptions` and pass it to your adapters:
```ts
import { Wooks } from 'wooks'
const wooks = new Wooks({
router: {
ignoreTrailingSlash: true, // `/path` and `/path/` match the same route
ignoreCase: true, // case-insensitive route matching
cacheLimit: 1000, // max parsed routes to cache
},
})
const jobs = new WooksJob({}, wooks)
```
### `lookup()` and `lookupHandlers()`
Both resolve handlers for `(method, path)` against an event context (defaults to `current()`), and as a side effect:
- write the matched route params to the context (the `routeParamsKey` slot, read by `useRouteParams()`)
- fire the `ContextInjector` hooks `'Handler:routed'` / `'Handler:not_found'` (see below)
`lookup()` returns `{ handlers, segments, firstStatic, path }` — all fields are `null` on a miss. `lookupHandlers()` is the fast variant: it returns just `TWooksHandler[] | null` without allocating a result object.
`getRouter()` exposes the underlying `ProstoRouter` for direct access.
For HTTP upgrade integration (`httpApp.ws(handler)`), the `WooksUpgradeHandler` type defines the contract a WebSocket-style adapter implements.
## Observability (`ContextInjector`)
`@wooksjs/event-core` ships a no-op `ContextInjector` base class. Subclass it and install your instance globally to add tracing, metrics, or logging around event lifecycle points — e.g. OpenTelemetry spans:
```ts
import { ContextInjector, replaceContextInjector } from '@wooksjs/event-core'
class OtelInjector extends ContextInjector {
with(name: string, attrs: Record, cb: () => T): T {
return tracer.startActiveSpan(name, (span) => {
span.setAttributes(attrs)
try { return cb() } finally { span.end() }
})
}
hook(method: string, name: 'Handler:routed' | 'Handler:not_found', route?: string) {
// record routing metrics
}
}
replaceContextInjector(new OtelInjector())
```
- `with(name, attributes, cb)` wraps a callback. The framework wraps every kinded event in `'Event:start'` with `{ eventType }` attributes — which is why adapter callbacks should return their results (sync or async) so the span covers the full handler execution.
- `hook(method, name, route?)` fires on every route lookup: `'Handler:routed'` with the matched route path, or `'Handler:not_found'`.
- `getContextInjector()` returns the installed injector, or `null` until one is installed.
- `resetContextInjector()` removes the installed injector, restoring the no-op default (useful in tests).
## Summary
- **Define an event kind:** `defineEventKind` with `slot()` markers declares your event's typed shape.
- **Build wooks:** Use `defineWook`, `key`, `cached` to provide clean APIs for accessing event-scoped data.
- **Create a context factory:** Export a function `(options, seeds, fn)` that calls `createEventContext(options, kind, seeds, fn)` with the kind hardcoded — the standard pattern across all adapters.
- **Extend `WooksAdapterBase`:** Use `this.on()` to register handlers and `this.wooks.lookup()` to find them. Use the context factory to run handlers inside the event context. **Return results** from callbacks to enable span tracking via [`ContextInjector`](#observability-contextinjector).
---
URL: "wooks.moost.org/wooks/advanced/wooks-context.html"
LLMS_URL: "wooks.moost.org/wooks/advanced/wooks-context.md"
---
# Event Context
`@wooksjs/event-core` provides the primitives for creating, managing, and accessing event contexts in Wooks. It enables strongly typed, per-event storage that persists through async calls without manual propagation. This guide targets advanced users who want to understand `event-core` or create custom event integrations.
## Core Concepts
Every event in Wooks runs inside an `EventContext` — a lightweight container backed by `AsyncLocalStorage`. The context holds typed **slots** that wooks read and write. There is no string-keyed store; instead, every piece of data has a typed `Key` or `Cached` slot.
### Primitives
| Primitive | Purpose |
|-----------|---------|
| `key(name)` | Creates a typed slot for explicit read/write values |
| `cached(fn)` | Creates a lazily-computed slot (runs `fn` once per context, caches result) |
| `cachedBy(fn)` | Like `cached`, but parameterized — one cached result per unique key |
| `slot()` | Marker used inside `defineEventKind` schemas |
| `defineEventKind(name, schema)` | Declares a named event kind with typed seed slots |
| `defineWook(factory)` | Creates a cached wook (factory runs once per context) |
## Creating an Event Context
### `createEventContext()`
Creates a new `EventContext`, makes it the active context via `AsyncLocalStorage`, and runs the provided callback inside it.
**Signatures:**
```ts
// Simple context (no event kind)
function createEventContext(
options: EventContextOptions,
fn: () => R,
): R
// Context with an event kind and seed values
function createEventContext, R>(
options: EventContextOptions,
kind: EventKind,
seeds: EventKindSeeds>,
fn: () => R,
): R
```
The kindless overload is a convenience for tests — it performs no seeding and skips `ContextInjector` instrumentation. Production adapters should always provide an event kind.
**Example:**
```ts
import { createEventContext, defineEventKind, slot } from '@wooksjs/event-core'
const myKind = defineEventKind('my-event', {
payload: slot(),
})
createEventContext({ logger }, myKind, { payload: data }, () => {
// Inside this callback, the event context is active
// All wooks and current() work here
})
```
### `run()`
```ts
function run(ctx: EventContext, fn: () => R): R
```
Use `run()` when you already have an `EventContext` — e.g. one created with `new EventContext(options)` — and need to activate it for a callback. All wooks and `current()` calls inside `fn` resolve to `ctx`. `createEventContext()` uses it internally.
### `current()` and `tryGetCurrent()`
```ts
function current(): EventContext // throws if no active context
function tryGetCurrent(): EventContext | undefined // returns undefined if none
```
`current()` returns the active `EventContext`. All wooks use it internally.
## Working with the EventContext
### Reading and Writing Slots
```ts
const ctx = current()
// Explicit key — you set and get values manually
ctx.set(myKey, value)
const val = ctx.get(myKey)
// Cached slot — computed lazily on first access
const val = ctx.get(myCachedSlot) // runs the factory function once, caches result
```
### Checking Slots
`ctx.has(accessor)` returns `true` if the slot has been set or computed — in this context or any parent. Use it to read optional state without triggering the "Key is not set" error:
```ts
const status = ctx.has(statusKey) ? ctx.get(statusKey) : 'pending'
```
### `key(name)`
Creates a named, typed slot for storing explicit values. You must `set` the value before `get`-ting it (otherwise an error is thrown).
```ts
import { key } from '@wooksjs/event-core'
const userIdKey = key('userId')
// In context:
ctx.set(userIdKey, '123')
ctx.get(userIdKey) // '123'
```
### `cached(fn)`
Creates a lazily-computed slot. The factory function runs once per context on first access. The result is cached — subsequent calls return the same value.
```ts
import { cached } from '@wooksjs/event-core'
const parsedUrl = cached((ctx) => new URL(ctx.get(requestUrlKey)))
// In context:
ctx.get(parsedUrl) // computes on first call, cached after
ctx.get(parsedUrl) // returns cached result
```
If the factory throws, the error is cached and re-thrown on subsequent access. Circular dependencies are detected and throw immediately.
### `cachedBy(fn)`
A parameterized version of `cached`. Maintains a `Map` per context — one cached result per unique key argument.
```ts
import { cachedBy } from '@wooksjs/event-core'
const parseCookieValue = cachedBy((name: string, ctx) => {
const cookie = ctx.get(reqKey).headers.cookie
// ... parse the cookie named `name`
return value
})
// In context:
parseCookieValue('session') // computed and cached for 'session'
parseCookieValue('theme') // computed and cached for 'theme'
parseCookieValue('session') // returns cached result
```
## Parent Contexts
`EventContextOptions` accepts an optional `parent` context, forming a chain. This is how child events — workflow steps started from an HTTP request, or WebSocket messages tied to their upgrade request — transparently expose the parent's data:
```ts
const child = new EventContext({ logger, parent: parentCtx })
```
- `get()` reads through the chain — if a slot is not found locally, parent contexts are consulted.
- `set()` writes to the nearest context in the chain that already holds the slot; if none does, the value is stored locally.
- `has()` checks the whole chain.
To bypass the chain, use the `*Own` variants, which operate on the current context only:
```ts
ctx.getOwn(myKey) // ignores parents
ctx.setOwn(myKey, value) // always stores locally
ctx.hasOwn(myKey) // true only if set/computed locally
```
See [HTTP Integration for workflows](/wf/http-integration) and [WebSocket composables](/wsapp/composables) for applied examples of parent-chain contexts.
## Defining Event Kinds
An `EventKind` declares the shape of an event — the named slots that must be seeded when the context is created.
```ts
import { defineEventKind, slot } from '@wooksjs/event-core'
const httpKind = defineEventKind('http', {
req: slot(),
response: slot(),
requestLimits: slot(),
})
```
Each property in the schema becomes a typed `Key` accessible via `kind.keys`:
```ts
const req = ctx.get(httpKind.keys.req) // IncomingMessage
```
### Seeding with `ctx.seed()`
When creating an event context for a specific kind, seed values are provided via `ctx.seed(kind, seeds)`:
```ts
ctx.seed(httpKind, {
req: incomingMessage,
response: httpResponse,
requestLimits: limits,
})
```
This is typically done inside `createEventContext()` or inside an adapter's request handler.
## Standard Keys
`@wooksjs/event-core` exports two predefined keys available across all event types:
- `routeParamsKey` — route parameters, written by `Wooks.lookup()`/`lookupHandlers()` during routing and read by `useRouteParams()`.
- `eventTypeKey` — the event kind name (`'http'`, `'CLI'`, `'WF'`, `'ws:connection'`, `'ws:message'`), set by `ctx.seed()`. Useful for detecting the current event type.
```ts
import { eventTypeKey } from '@wooksjs/event-core'
const type = current().get(eventTypeKey) // e.g. 'http'
```
## Building Wooks
### `defineWook(factory)`
The recommended way to create wooks. Wraps a factory function with per-context caching — the factory runs once per event context, and subsequent calls return the cached result.
```ts
import { defineWook } from '@wooksjs/event-core'
export const useMyFeature = defineWook((ctx) => {
const req = ctx.get(httpKind.keys.req)
return {
getHeader: (name: string) => req.headers[name],
getMethod: () => req.method,
}
})
```
Usage:
```ts
const { getHeader, getMethod } = useMyFeature()
```
The optional `ctx` parameter lets you pass an explicit context (useful in tests):
```ts
const result = useMyFeature(testCtx)
```
`defineWook` returns a `WookComposable` — callable as `(ctx?) => T` — that also exposes a readonly `_slot` property: the underlying `Cached` slot. This is useful in advanced scenarios such as building slot-isolation lists for child contexts.
## Best Practices
1. **Use `defineWook` for wooks.** It handles caching automatically — your factory runs once per event, and the returned object is reused.
2. **Use `cached` for derived data.** If a value can be computed from other context data, make it a `cached` slot rather than computing it in multiple places.
3. **Use `key` for mutable state.** When you need to store values that change during event processing (e.g., a status field), use explicit keys.
4. **Type everything.** All primitives are generic — `key`, `cached`, `slot` — so consumers get full type safety with no casts.
5. **Avoid accessing context outside event scope.** `current()` throws if called outside an active context. Guard with `tryGetCurrent()` when context may not be available.
---
URL: "wooks.moost.org/wooks/generic-wooks.html"
LLMS_URL: "wooks.moost.org/wooks/generic-wooks.md"
---
# Generic Wooks
[What is a wook?](/wooks/what#what-is-a-wook)
Wooks provides a set of generic wooks that work across all event "flavors" (HTTP, CLI, Workflow, or custom). These wooks give you access to core event properties — such as event IDs, logging, and route parameters — regardless of the underlying event type.
[[toc]]
## Overview
These wooks are defined in the `@wooksjs/event-core` package, but they are re-exported by the main `wooks` library. You can import them directly from `'wooks'`.
**Example Import:**
```ts
import { useEventId, useLogger, useRouteParams } from 'wooks'
```
## `useEventId()`
**Signature:**
```ts
function useEventId(ctx?: EventContext): {
getId: () => string
}
```
**Description:**
Provides a unique, per-event identifier. Useful for tracking and correlating logs or data associated with an individual event. The ID is a random UUID, generated lazily on first access and cached for the lifetime of the event.
**Example:**
```ts
const { getId } = useEventId()
console.log('Current Event ID:', getId())
```
## `useLogger()`
**Signatures:**
```ts
function useLogger(): Logger
function useLogger(topic: string): Logger
function useLogger(ctx: EventContext): Logger
function useLogger(topic: string, ctx: EventContext): Logger
```
**Description:**
Returns the `Logger` instance associated with the current event context. The logger supports standard log methods (`info`, `warn`, `error`, `debug`) and is configured when the event context is created.
When called with a `topic` string, creates a child logger via `logger.createTopic()` (if the logger supports it). This is useful for scoping log output to a specific area of your application. If the logger does not implement `createTopic`, the base logger is returned unchanged.
*Learn more about [Logging in Wooks](/wooks/advanced/logging) in the advanced section.*
**Examples:**
```ts
// Base event logger
const logger = useLogger()
logger.debug('Processing event')
logger.error('Something went wrong')
// Topic-scoped child logger
const authLogger = useLogger('auth')
authLogger.warn('Token expired')
```
## `useRouteParams()`
**Signature:**
```ts
function useRouteParams<
T extends Record = Record
>(ctx?: EventContext): {
params: T
get: (name: K) => T[K]
}
```
**Description:**
Accesses route parameters (e.g., path parameters in HTTP routes, command arguments in CLI mode, etc.). Returns a `params` object and a typed `get()` function to retrieve individual parameters by name.
**Example:**
```ts
const { params, get } = useRouteParams<{ id: string }>()
console.log('Route Params:', params)
console.log('ID Param:', get('id'))
```
## `current()`
**Signature:**
```ts
function current(): EventContext
```
**Description:**
Returns the active `EventContext` for the current async execution scope. This is the low-level primitive that all wooks use internally. Most application code should use higher-level wooks, but `current()` is available for advanced use cases or when building custom wooks.
Throws an error if called outside an event context.
*Learn more about [Event Context](/wooks/advanced/wooks-context) in the advanced section.*
**Example:**
```ts
import { current } from '@wooksjs/event-core'
const ctx = current()
```
## Summary
These generic wooks form the foundational layer of Wooks' event-driven approach:
- **`useEventId()`:** Get a unique event identifier.
- **`useLogger()`:** Access the event-scoped logger ([More About Logging in Wooks](/wooks/advanced/logging)).
- **`useRouteParams()`:** Access path or argument parameters for the current event.
- **`current()`:** Directly access the underlying event context for advanced scenarios ([More About Event Context](/wooks/advanced/wooks-context)).
---
URL: "wooks.moost.org/wooks/type-safety.html"
LLMS_URL: "wooks.moost.org/wooks/type-safety.md"
---
# Type Safety
Wooks provides compile-time type safety with zero runtime overhead. Every primitive — `key`, `cached`, `slot`, `defineEventKind`, `defineWook` — carries its type through the entire system. There are no casts, no `any` escape hatches, and no runtime type checking.
## How It Works
The core mechanism is **type branding**. Each slot stores a phantom type parameter `_T` that TypeScript tracks but JavaScript never sees:
```ts
interface Key {
readonly _id: number
readonly _name: string
readonly _T?: T // ← phantom type brand, not used at runtime
}
```
When you create a key, TypeScript locks in the type:
```ts
const userId = key('userId')
// userId is Key — TypeScript remembers this everywhere
```
## Typed Get/Set
`EventContext.get()` and `EventContext.set()` extract the type from the accessor:
```ts
get(accessor: Key | Cached): T
set(key: Key | Cached, value: T): void
```
Both operations share the same ``, tied to the accessor's type brand. TypeScript enforces consistency automatically:
```ts
const userId = key('userId')
ctx.set(userId, 'abc') // ✓ string matches Key
ctx.set(userId, 123) // ✗ Type 'number' is not assignable to 'string'
const id = ctx.get(userId) // id is string — no cast needed
```
## Inferred Factory Types
`cached` and `defineWook` infer their type from the factory function's return value — you never need to spell out the generic:
```ts
// TypeScript infers T = number from the return value
const requestSize = cached((ctx) => {
const req = ctx.get(httpKind.keys.req)
return parseInt(req.headers['content-length'] || '0')
})
ctx.get(requestSize) // number — inferred, not annotated
```
The same applies to `defineWook`:
```ts
// TypeScript infers the full return type from the factory
export const useJob = defineWook((ctx) => ({
getJobId: () => ctx.get(jobKind.keys.jobId),
getStatus: () => ctx.has(statusKey) ? ctx.get(statusKey) : 'pending',
}))
const { getJobId, getStatus } = useJob()
// getJobId: () => string
// getStatus: () => 'pending' | 'running' | 'done'
```
No manual type annotation. The factory return type flows through `defineWook` and becomes the wook's return type:
```ts
function defineWook(factory: (ctx: EventContext) => T): WookComposable
```
`WookComposable` is callable as `(ctx?: EventContext) => T` and exposes a readonly `_slot` — the underlying `Cached` slot, for advanced slot-isolation scenarios (see [Event Context](/wooks/advanced/wooks-context)).
## Parameterized Caching
`cachedBy` tracks two independent types — the key and the value:
```ts
const headerValue = cachedBy((name: string, ctx) => {
return ctx.get(httpKind.keys.req).headers[name] || ''
})
headerValue('content-type') // returns string
headerValue(42) // ✗ Type 'number' is not assignable to 'string'
```
## Event Kind Schemas
`defineEventKind` uses `slot()` markers to declare a typed schema. Each slot is a zero-cost type brand:
```ts
function slot(): SlotMarker // returns {} at runtime — purely a type marker
```
When you pass a schema to `defineEventKind`, TypeScript uses **conditional types with `infer`** to transform each `SlotMarker` into a `Key`:
```ts
type EventKind = {
keys: { [K in keyof S]: S[K] extends SlotMarker ? Key : never }
}
```
In practice:
```ts
const jobKind = defineEventKind('job', {
jobId: slot(),
payload: slot(),
priority: slot(),
})
// TypeScript infers:
// jobKind.keys.jobId → Key
// jobKind.keys.payload → Key
// jobKind.keys.priority → Key
ctx.get(jobKind.keys.priority) // number
```
## Seed Validation
`ctx.seed()` enforces that every slot in the schema is provided with the correct type. The seed type is derived from the schema using a mapped conditional type:
```ts
type EventKindSeeds =
K extends EventKind
? { [P in keyof S]: S[P] extends SlotMarker ? V : never }
: never
```
This means:
```ts
// ✓ All slots provided with correct types
ctx.seed(jobKind, {
jobId: 'abc',
payload: { data: 1 },
priority: 5,
})
// ✗ Missing 'priority'
ctx.seed(jobKind, {
jobId: 'abc',
payload: { data: 1 },
})
// ✗ Wrong type for 'priority'
ctx.seed(jobKind, {
jobId: 'abc',
payload: { data: 1 },
priority: 'high', // Type 'string' is not assignable to 'number'
})
```
No runtime validation code. The compiler catches mismatches before you run anything.
## Summary
| Primitive | Type mechanism | What it enforces |
|-----------|---------------|-----------------|
| `key()` | Phantom type brand | `get`/`set` must use the same type |
| `cached(fn)` | Inferred from factory return | Read type matches computed type |
| `cachedBy(fn)` | Two independent generics | Key and value types from factory signature |
| `slot()` | Type-level marker (zero runtime cost) | Schema slot type for `defineEventKind` |
| `defineEventKind` | Mapped type with `infer` | Transforms `SlotMarker` → `Key` for each slot |
| `defineWook(fn)` | Inferred from factory return | Wook return type matches factory return type |
| `ctx.seed()` | Conditional mapped type | All required slots present with correct types |
All type safety is compile-time only — no runtime checks, no `instanceof`, no validation libraries. The context is a flat `Map` at runtime, with TypeScript enforcing correctness through generics, phantom types, and conditional type inference.
---
URL: "wooks.moost.org/wooks/what.html"
LLMS_URL: "wooks.moost.org/wooks/what.md"
---
# What is Wooks?
Wooks is a TypeScript-first event-processing framework for Node.js built around one idea: **event context should be a first-class citizen**.
The name comes from **w**(eb) (h)**ooks** — but the concept goes far beyond HTTP. Wooks handles HTTP requests, CLI commands, WebSocket messages, workflows, and custom event types through a single architecture.
## The Problem
When you handle an HTTP request, you need context — route params, body, cookies, auth, user info, IP address. As your app grows, so does the pile of context you need per event.
Traditional frameworks bolt context onto a mutable `req` or `event` object — untyped, eagerly computed, middleware-order dependent. See [Why Wooks?](/wooks/why) for the full comparison.
Wooks was built to solve this.
## The Solution: Typed Event Context
Wooks gives every event — HTTP request, CLI command, workflow step — a **typed context** with built-in support for lazy computation and automatic caching.
The API feels natural: call `useRequest()`, `useBody()`, or `useAuthorization()` from anywhere in your code. No passing `req` around, no `event` threading. These composable functions read from the current event's context automatically, thanks to `AsyncLocalStorage`.
## What is a Wook?
A **wook** is a composable function for the backend — inspired by Vue's Composition API. Call it inside any event handler and get back typed, per-event data. No arguments needed, no event object to pass around.
```ts
const { params, get } = useRouteParams<{ id: string }>()
const logger = useLogger()
logger.info('Processing event for', get('id'))
```
Wooks are lazy — nothing is parsed or computed until you actually call one.
Under the hood, each wook reads from an `EventContext` propagated via `AsyncLocalStorage`. You never see it directly, but it's why wooks work anywhere in the call stack, through any number of `await` boundaries — just like Vue composables work anywhere inside `setup()`.
## `defineWook`
Every built-in wook is created with `defineWook` — and yours can be too:
```ts
import { defineWook, key } from '@wooksjs/event-core'
const itemsKey = key('items')
export const useItems = defineWook((ctx) => ({
getItems: () => ctx.has(itemsKey) ? ctx.get(itemsKey) : [],
setItems: (items: string[]) => ctx.set(itemsKey, items),
}))
```
The factory runs once per event. After that, every call to `useItems()` within the same event returns the cached result. This is the same mechanism behind every built-in wook in the framework.
## Context Primitives
The whole context system is built on a handful of simple primitives:
| Primitive | What it does |
|-----------|-------------|
| `key(name)` | A writable typed slot — `set` and `get` values explicitly |
| `cached(fn)` | A read-only slot — computed lazily on first access, cached for the event lifetime |
| `cachedBy(fn)` | Like `cached`, but keyed — one cached result per unique argument |
| `defineEventKind(name, slots)` | Declares a named event schema with typed seed slots |
| `defineWook(factory)` | Creates a wook with per-event caching |
No string-keyed stores, no `Object.defineProperty` magic. Under the hood it's a flat `Map` with compile-time type safety layered on top.
## Event Lifecycle
Every interaction in Wooks follows the same simple flow:
1. Create an `EventContext`
2. Seed it with event-specific data
3. Look up and run handlers
4. Wooks pull data from context on demand
This is the same regardless of event type — HTTP, CLI, workflow, or anything custom. You can define your own event kinds with `defineEventKind` and build adapters that follow the same pattern. Wooks that depend only on shared context work across all event types unchanged.
## Domain Adapters
Wooks ships with adapters for the most common event types:
- **[HTTP](/webapp/)** (`@wooksjs/event-http`) — Web servers and REST APIs with request/response wooks, body parsing, static files, and reverse proxy.
- **[WebSocket](/wsapp/)** (`@wooksjs/event-ws` + `@wooksjs/ws-client`) — Real-time servers with routed message handlers, rooms, and a zero-dependency client with RPC and reconnection.
- **[CLI](/cliapp/)** (`@wooksjs/event-cli`) — Command-line tools with option/argument wooks, auto-generated help, and the same routing engine as HTTP.
- **[Workflows](/wf/)** (`@wooksjs/event-wf`) — Multi-step pipelines with step/flow wooks, input handling, and pause/resume.
You can also [build your own adapter](/wooks/advanced/wooks-adapter) for any event-driven scenario.
## Package Structure
| Package | Role |
|---------|------|
| @wooksjs/event-core | Context primitives: `key`, `cached`, `defineWook`, `defineEventKind` |
| [@prostojs/router](https://github.com/prostojs/router) | Standalone high-performance router ([benchmarks](/benchmarks/router)) |
| @wooksjs/event-http | HTTP adapter, request/response wooks ([benchmarks](/benchmarks/wooks-http)) |
| @wooksjs/event-ws | WebSocket adapter, connection/message/rooms wooks |
| @wooksjs/ws-client | WebSocket client (browser + Node) |
| @wooksjs/event-cli | CLI adapter, option/argument wooks |
| @wooksjs/event-wf | Workflow adapter, step/flow wooks |
| @wooksjs/http-body | Body parser wook |
| @wooksjs/http-static | Static file serving |
| @wooksjs/http-proxy | Reverse proxy wook |
---
URL: "wooks.moost.org/wooks/why.html"
LLMS_URL: "wooks.moost.org/wooks/why.md"
---
# Why Wooks?
## The Core Insight
Every backend framework needs to manage event context — the accumulated state of a request as it flows through your app. Who is the user? What are the route params? What's in the body? What permissions do they have?
Most frameworks treat this as an afterthought:
- **Express / Fastify** let you tack properties onto `req` — an untyped grab bag that grows with every middleware. Want the user's role? Hope the `authenticate` middleware ran first and set `req.user`. Want type safety? Manually extend the `Request` interface and hope reality matches.
- **h3** has a cleaner `event` object, but no built-in system for extending it type-safely or computing properties lazily. You end up writing the same ad-hoc patterns.
Wooks treats **event context as the central design problem**:
- **Typed slots** — declare context properties with compile-time types, not string keys on a mutable object
- **Lazy computation** — nothing runs until accessed; `cached()` slots compute on first read and stay cached
- **Automatic caching** — call a wook ten times, it only does work once
- **Type-safe extensibility** — `defineWook()` lets you create composables that extend the context without touching any global object
The composable API — `useRequest()`, `useBody()`, `useAuthorization()` — is what naturally emerges from this. It's not syntactic sugar over `req` and `res`. It's what proper event context management actually looks like.
## The Origin Story
We were building backend services while using Vue's Composition API heavily on the frontend. The contrast was painful.
In Vue, you write `useAuth()` and get back credentials, state, and actions — typed, cached, self-contained. On the backend, you'd pass context objects through every function, parse data eagerly in middleware, and pray the types lined up.
The question was simple: **why can't Vue-style composables exist on the server?**
Turns out they can — thanks to `AsyncLocalStorage`. It gives you per-event context propagation, the same concept that makes `ref()` and `computed()` work in Vue's setup functions, but for async handlers. Once we had that, the pattern mapped naturally to any event type: HTTP requests, CLI commands, workflows, custom events. We called these server-side composables **wooks** — from **w**(eb) (h)**ooks**.
## The Design Decisions
### Lazy by default
Traditional frameworks parse and compute everything in middleware before your handler even runs. Reject the request early? Too bad — you already paid for all that work. Wooks does nothing until you ask. Data is parsed on demand, computed lazily, and cached for the event lifetime. It's not just faster — it makes the code honest about what it actually does.
### No middleware pipeline
Middleware forces you to think about ordering — get it wrong and the bugs are subtle. Wooks have no ordering. They read from context, they write to context, and they can be called from anywhere. There's no pipeline to break, no "did that middleware run yet?" guessing games.
### One pattern for everything
HTTP, CLI, and workflows share the same `EventContext`, the same `defineWook`, the same `key`/`cached` primitives. Write a logging wook once — it works in an HTTP handler, a CLI handler, and a workflow step, unchanged. The framework doesn't care what kind of event triggered the handler. It only cares about what's in the context.
### Types that actually work
`key('userId')` gives you a `Key`. `defineWook((ctx) => ({ ... }))` infers the return type. No indirection, no string casts, no manual interface extensions. Every primitive is generic — type safety comes for free.
## Fast Where It Matters
Wooks isn't just well-designed — it's fast. In a [production-realistic benchmark](/benchmarks/wooks-http) simulating a real SaaS API with auth, cookies, and body parsing, Wooks leads all tested frameworks — ahead of Fastify, h3, Hono, and Express.
The lazy architecture pays off most where it matters: cookie-heavy browser traffic (the most common SaaS pattern) and early rejection of bad requests. When auth fails, Wooks skips body parsing entirely — dramatically faster than frameworks that parse eagerly.
The underlying [`@prostojs/router`](https://github.com/prostojs/router) is the fastest router on deeply-nested parametric routes while offering the richest feature set — regex constraints, multiple wildcards, optional parameters — that trie-based alternatives can't match. See the [full benchmark analysis](/benchmarks/wooks-http) for details.
## When to Use Wooks
Wooks is a great fit if you want backend code that reads like Vue composables — function calls that return typed data, no ceremony. It shines when you're handling multiple event types (HTTP, CLI, workflows) with a consistent API, when you care about TypeScript ergonomics, or when you want explicit control over what work actually gets done per event.
---
URL: "wooks.moost.org/wsapp"
LLMS_URL: "wooks.moost.org/wsapp.md"
---
# Get Started with WebSocket
::: warning Experimental
This package is in an experimental phase. The API may change without following semver until it reaches a stable release.
:::
::: info
Learn more about Wooks to understand its philosophy and advantages:
- [What is Wooks?](/wooks/what)
- [Why Wooks?](/wooks/why)
- [Introduction to Wooks WebSocket](/wsapp/introduction)
:::
## Installation
The WebSocket flavor uses two packages — one for the server, one for the client:
```bash
# Server
npm install @wooksjs/event-ws @wooksjs/event-http ws
# Client (browser or Node.js)
npm install @wooksjs/ws-client
```
`@wooksjs/event-http` is needed when you serve WebSocket alongside HTTP routes (integrated mode). For a standalone WebSocket server, only `@wooksjs/event-ws` and `ws` are required (see [Standalone Server](#standalone-server) below). The `ws` package provides the underlying WebSocket implementation for Node.js.
### AI Agent Skills
Wooks 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 wooksjs/wooksjs
```
Learn more about AI agent skills at [skills.sh](https://skills.sh).
## Server: Hello World
A minimal server that echoes messages back to the sender.
```ts
import { createHttpApp } from '@wooksjs/event-http'
import { createWsApp, useWsMessage, useWsConnection } from '@wooksjs/event-ws'
const http = createHttpApp()
const ws = createWsApp(http) // auto-registers as upgrade handler
// HTTP route that upgrades to WebSocket
http.upgrade('/ws', () => ws.upgrade())
// Handle "echo" events on any path
ws.onMessage('echo', '/*', () => {
const { data, path } = useWsMessage()
return { echoed: data, path } // → sent back as reply
})
http.listen(3000, () => {
console.log('WebSocket server ready on ws://localhost:3000/ws')
})
```
Messages are routed by **event type** (like an HTTP method) and **path** (like a URL). The handler return value is sent back as a reply when the client used `call()` (RPC).
## Client: Connecting
### Browser
```ts
import { createWsClient } from '@wooksjs/ws-client'
const client = createWsClient('ws://localhost:3000/ws', {
reconnect: true,
rpcTimeout: 5000,
})
client.onOpen(() => console.log('Connected!'))
// RPC — returns a Promise
const result = await client.call('echo', '/hello', { message: 'Hi!' })
console.log(result) // → { echoed: { message: 'Hi!' }, path: '/hello' }
```
### Node.js
Node.js 22+ has a native global `WebSocket` — the browser example above works as-is, no extra setup. On Node.js < 22, install the `ws` package and expose it as a global polyfill before creating the client:
```ts
import WebSocket from 'ws'
import { createWsClient } from '@wooksjs/ws-client'
globalThis.WebSocket ??= WebSocket as any // [!code hl]
const client = createWsClient('ws://localhost:3000/ws', {
rpcTimeout: 5000,
})
```
## Adding Rooms
A chat server where clients join rooms and broadcast messages.
```ts
import { createHttpApp } from '@wooksjs/event-http'
import {
createWsApp,
useWsMessage,
useWsRooms,
WsError,
} from '@wooksjs/event-ws'
const http = createHttpApp()
const ws = createWsApp(http)
http.upgrade('/ws', () => ws.upgrade())
// Join a room
ws.onMessage('join', '/chat/:room', () => {
const { data } = useWsMessage<{ name: string }>()
if (!data?.name) throw new WsError(400, 'Name is required')
const { join, broadcast } = useWsRooms()
join() // joins the room matching the current path: /chat/:room
broadcast('system', { text: `${data.name} joined` })
return { joined: true }
})
// Send a message to a room
ws.onMessage('message', '/chat/:room', () => {
const { data } = useWsMessage<{ text: string; from: string }>()
const { broadcast } = useWsRooms()
broadcast('message', data) // sends to all room members except sender
})
http.listen(3000)
```
```ts
// Client usage
const client = createWsClient('ws://localhost:3000/ws')
await client.call('join', '/chat/general', { name: 'Alice' })
client.on('message', '/chat/general', ({ data }) => {
console.log(`${data.from}: ${data.text}`)
})
client.on('system', '/chat/general', ({ data }) => {
console.log(`[system] ${data.text}`)
})
client.send('message', '/chat/general', { text: 'Hello!', from: 'Alice' })
```
## Standalone Server
If you don't need HTTP routes alongside WebSocket, use standalone mode:
```ts
import { createWsApp, useWsMessage } from '@wooksjs/event-ws'
const ws = createWsApp()
ws.onMessage('echo', '/*', () => {
const { data } = useWsMessage()
return data
})
ws.listen(3000)
```
In standalone mode, all connections are accepted — there's no HTTP upgrade route to configure.
### Shutting down
Shut down with `ws.close()` — it stops the heartbeat and closes every connection with code `1001` (Server shutting down). Note that `close()` does not close the underlying HTTP server created by `listen()`; retrieve it with `ws.getServer()` and close it yourself:
```ts
ws.close()
ws.getServer()?.close()
```
In HTTP-integrated mode, `getServer()` returns `undefined` — the HTTP server belongs to `@wooksjs/event-http`.
## Next Steps
- [Introduction](/wsapp/introduction) — Philosophy and an overview of the server and client packages.
- [Composables](/wsapp/composables) — Full reference for all server composables and server options.
- [Rooms & Broadcasting](/wsapp/rooms) — Deep dive into room management and broadcasting.
- [Client Guide](/wsapp/client) — Complete client API: RPC, subscriptions, reconnection, error handling.
- [Wire Protocol](/wsapp/protocol) — Understand the JSON message format.
- [Testing](/wsapp/testing) — Run WS handlers in isolated test contexts.
- [Logging](/wsapp/logging) — Event-scoped logging in WS handlers.
---
URL: "wooks.moost.org/wsapp/client.html"
LLMS_URL: "wooks.moost.org/wsapp/client.md"
---
# Client Guide
::: warning Experimental
This package is in an experimental phase. The API may change without following semver until it reaches a stable release.
:::
`@wooksjs/ws-client` is a structured WebSocket client for browsers and Node.js. It provides RPC calls with automatic correlation, fire-and-forget messaging, push listeners with path matching, subscriptions with auto-resubscribe, and reconnection with backoff.
Zero runtime dependencies. Uses native `WebSocket` in browsers.
[[toc]]
## Installation
```bash
npm install @wooksjs/ws-client
```
Node.js 22+ and browsers need nothing extra. For Node.js < 22, also install the `ws` package:
```bash
npm install @wooksjs/ws-client ws
```
## Creating a Client
### Browser
```ts
import { createWsClient } from '@wooksjs/ws-client'
const client = createWsClient('ws://localhost:3000/ws', {
reconnect: true,
rpcTimeout: 5000,
})
```
### Node.js
Node.js 22+ has a native global `WebSocket` — the browser example above works as-is. On Node.js < 22, expose the `ws` package as a global polyfill before creating the client:
```ts
import WebSocket from 'ws'
import { createWsClient } from '@wooksjs/ws-client'
globalThis.WebSocket ??= WebSocket as any
const client = createWsClient('ws://localhost:3000/ws', {
rpcTimeout: 5000,
})
```
### Options
```ts
interface WsClientOptions {
protocols?: string | string[] // WebSocket subprotocols
reconnect?: boolean | WsClientReconnectOptions // Enable reconnection
rpcTimeout?: number // Timeout for call() in ms (default: 10000)
messageParser?: (raw: string) => any // Custom deserializer (default: JSON.parse)
messageSerializer?: (msg: any) => string // Custom serializer (default: JSON.stringify)
}
```
## Sending Messages
### Fire-and-forget: `send()`
Send a message without expecting a reply. The server handler's return value is ignored.
```ts
client.send('message', '/chat/general', { text: 'Hello!' })
```
Wire frame: `{ event: "message", path: "/chat/general", data: { text: "Hello!" } }`
When disconnected with reconnect enabled, messages are **queued** and sent when the connection reopens. Without reconnect, they are silently dropped.
### RPC: `call()`
Send a message and wait for the server's reply. Returns a typed Promise.
```ts
const result = await client.call<{ joined: boolean }>('join', '/chat/general', { name: 'Alice' })
console.log(result.joined) // → true
```
Wire frame: `{ event: "join", path: "/chat/general", data: { name: "Alice" }, id: 1 }`
The client auto-generates an incrementing numeric `id`. The server matches the reply by this ID.
#### Error handling
`call()` rejects with `WsClientError` in these cases:
| Scenario | Error code |
|----------|-----------|
| Not connected when called | 503 |
| Connection lost while waiting for reply | 503 |
| `client.close()` called while waiting | 503 |
| Timeout (`rpcTimeout` exceeded) | 408 |
| Server sent an error reply | Server's error code |
```ts
import { WsClientError } from '@wooksjs/ws-client'
try {
await client.call('join', '/chat/general', { name: 'Alice' })
} catch (err) {
if (err instanceof WsClientError) {
if (err.code === 409) console.log('Name already taken')
if (err.code === 408) console.log('Request timed out')
}
}
```
## Listening for Push Messages
### `on()`
Register a handler for server-initiated push messages. Returns an unregister function.
```ts
const off = client.on('message', '/chat/general', ({ event, path, params, data }) => {
console.log(`${data.from}: ${data.text}`)
})
// Later: stop listening
off()
```
#### Handler signature
```ts
interface WsClientPushEvent {
event: string // Event type from server
path: string // Concrete path from server
params: Record // Route params extracted by server (e.g. { room: 'general' })
data: T // Payload
}
```
Route params are extracted by the **server** router and included in the push message. The client does not parse them.
#### Path matching
**Exact match** — O(1) lookup:
```ts
client.on('message', '/chat/general', handler)
// Matches: /chat/general
// Ignores: /chat/random, /chat/general/sub
```
**Wildcard suffix** — prefix matching:
```ts
client.on('message', '/chat/*', handler)
// Matches: /chat/general, /chat/random, /chat/general/sub
// Ignores: /users/42
```
Both exact and wildcard listeners fire if they match the same message. Multiple handlers for the same pattern are all called.
## Subscriptions
### `subscribe()`
Subscribe to a path with server acknowledgment and automatic resubscribe on reconnect.
```ts
const unsub = await client.subscribe('/notifications')
// Later: unsubscribe
unsub()
```
Under the hood:
1. Sends `{ event: "subscribe", path: "/notifications", id: N }` (RPC)
2. Waits for server acknowledgment
3. Tracks the subscription for auto-resubscribe after reconnect
4. Returns an unsubscribe function that sends `{ event: "unsubscribe", path: "/notifications" }` (fire-and-forget) and removes the tracking
You still need `on()` to handle the push messages that arrive on the subscribed path.
## Lifecycle Events
All lifecycle handlers return an unregister function. Multiple handlers can be registered for each event.
### onOpen
Fires when the connection opens, including after reconnect.
```ts
const off = client.onOpen(() => {
console.log('Connected!')
})
```
After reconnect: queued messages are flushed first, then subscriptions are resubscribed, then `onOpen` fires.
### onClose
Fires on every close, including before reconnect attempts.
```ts
client.onClose((code, reason) => {
console.log(`Disconnected: ${code} ${reason}`)
})
```
### onError
Fires on WebSocket error events.
```ts
client.onError((error) => {
console.error('WebSocket error:', error)
})
```
### onReconnect
Fires before each reconnection attempt, after the backoff delay.
```ts
client.onReconnect((attempt) => {
console.log(`Reconnecting... attempt ${attempt}`)
})
```
## Closing
```ts
client.close()
```
Closes the WebSocket with code 1000. Permanently disables reconnection. Rejects all pending RPCs with `WsClientError(503, 'Connection closed')`. Clears the message queue.
## Reconnection
Enable reconnection to automatically recover from dropped connections:
```ts
const client = createWsClient(url, {
reconnect: true, // uses defaults
})
// Or with custom options:
const client = createWsClient(url, {
reconnect: {
enabled: true,
maxRetries: 10, // default: Infinity
baseDelay: 1000, // ms, default: 1000
maxDelay: 30000, // ms, default: 30000
backoff: 'exponential', // 'exponential' | 'linear', default: 'exponential'
},
})
```
### Backoff
- **Exponential** (default): `min(baseDelay × 2^attempt, maxDelay)` → 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
- **Linear**: `min(baseDelay × (attempt + 1), maxDelay)` → 1s, 2s, 3s, 4s, ...
### What happens on unexpected close
1. All pending RPCs are rejected (code 503, "Connection lost")
2. `onClose` handlers fire
3. After backoff delay: `onReconnect` handlers fire, new WebSocket is created
4. On successful open: queued messages are flushed, subscriptions are resubscribed, `onOpen` fires
### Re-joining rooms after reconnect
Reconnection restores the WebSocket connection and resubscribes `subscribe()` calls, but it does not automatically re-join rooms. Handle this in `onOpen`:
```ts
client.onOpen(() => {
// Re-join the room after reconnect
client.call('join', `/chat/${currentRoom}`, { name: myName })
})
```
## Complete Example
```ts
import { createWsClient, WsClientError } from '@wooksjs/ws-client'
const client = createWsClient('ws://localhost:3000/ws', {
reconnect: true,
rpcTimeout: 5000,
})
// Wait for connection
await new Promise(resolve => client.onOpen(resolve))
// Join a room (RPC)
try {
await client.call('join', '/chat/general', { name: 'Alice' })
} catch (err) {
if (err instanceof WsClientError && err.code === 409) {
console.log('Name taken, try another')
}
throw err
}
// Listen for messages
client.on('message', '/chat/general', ({ data }) => {
console.log(`${data.from}: ${data.text}`)
})
client.on('system', '/chat/general', ({ data }) => {
console.log(`[system] ${data.text}`)
})
// Send a message (fire-and-forget)
client.send('message', '/chat/general', { text: 'Hello everyone!' })
// Leave and close
await client.call('leave', '/chat/general')
client.close()
```
---
URL: "wooks.moost.org/wsapp/composables.html"
LLMS_URL: "wooks.moost.org/wsapp/composables.md"
---
# WebSocket Composables
::: warning Experimental
This package is in an experimental phase. The API may change without following semver until it reaches a stable release.
:::
Composables for working with WebSocket connections, messages, rooms, and server state. Most composables follow the Wooks `defineWook` pattern — results are cached per context and resolved lazily; `useWsServer()` is a plain function.
[[toc]]
## useWsConnection
Returns connection-level information. Available in both connection handlers (`onConnect`, `onDisconnect`) and message handlers (`onMessage`).
```ts
import { useWsConnection } from '@wooksjs/event-ws'
ws.onMessage('query', '/me', () => {
const { id, send, close } = useWsConnection()
return { connectionId: id }
})
```
| Property | Type | Description |
|----------|------|-------------|
| `id` | `string` | Unique connection ID (`crypto.randomUUID()`) |
| `send` | `(event, path, data?, params?) => void` | Send a push message directly to this connection |
| `close` | `(code?, reason?) => void` | Close the underlying WebSocket |
| `context` | `EventContext` | The connection-level event context |
### Direct push to a connection
```ts
const { send } = useWsConnection()
// Send a push message to this specific connection
send('notification', '/alerts', { text: 'New message' })
// With route params (received by client in the push event)
send('update', '/users/42', { name: 'Alice' }, { id: '42' })
```
Push messages bypass room membership — they go directly to the connection regardless of which rooms it has joined.
### currentConnection()
A lower-level escape hatch that returns the connection-level `EventContext` from either handler type — `current()` in `onConnect`/`onDisconnect`, or `current().parent` (the connection context) in `onMessage`. Most code should use `useWsConnection()` instead; reach for `currentConnection()` only when you need the raw context to read/write connection-scoped slots directly.
```ts
import { currentConnection } from '@wooksjs/event-ws'
const connCtx = currentConnection() // connection EventContext, from any WS handler
```
## useWsMessage
Returns the current message payload and metadata. Available **only** in message handlers (`onMessage`).
```ts
import { useWsMessage } from '@wooksjs/event-ws'
ws.onMessage('message', '/chat/:room', () => {
const { data, raw, id, path, event } = useWsMessage<{ text: string }>()
console.log(event) // → 'message'
console.log(path) // → '/chat/general'
console.log(data) // → { text: 'hello' }
return { received: true }
})
```
| Property | Type | Description |
|----------|------|-------------|
| `data` | `T` | Parsed message payload (generic type parameter) |
| `raw` | `Buffer \| string` | Raw message before parsing |
| `id` | `string \| number \| undefined` | Correlation ID — present when client used `call()` |
| `path` | `string` | Concrete message path (e.g. `/chat/general`) |
| `event` | `string` | Event type (e.g. `message`, `join`, `subscribe`) |
### Handler return values
What your handler returns determines the server's response:
| Situation | Server behavior |
|-----------|----------------|
| Client sent `id`, handler returns value | Reply: `{ id, data: }` |
| Client sent `id`, handler returns `undefined` | Reply: `{ id, data: null }` |
| Client sent no `id` (fire-and-forget) | Return value is ignored |
| Handler throws `WsError` | Reply: `{ id, error: { code, message } }` (if `id` present) |
| Handler throws other `Error` | Reply: `{ id, error: { code: 500, message: "Internal Error" } }` |
## useWsRooms
Room management and broadcasting. Available **only** in message handlers (`onMessage`). All methods default to the current message path as the room name.
```ts
import { useWsRooms } from '@wooksjs/event-ws'
ws.onMessage('join', '/chat/:room', () => {
const { join, broadcast, rooms } = useWsRooms()
join() // joins room = current path (/chat/general)
broadcast('system', { text: 'Someone joined' })
return { rooms: rooms() }
})
```
| Method | Signature | Description |
|--------|-----------|-------------|
| `join` | `(room?: string) => void` | Join a room. Defaults to current message path. |
| `leave` | `(room?: string) => void` | Leave a room. Defaults to current message path. |
| `broadcast` | `(event, data?, options?) => void` | Broadcast to all connections in a room. |
| `rooms` | `() => string[]` | List all rooms this connection has joined. |
See [Rooms & Broadcasting](/wsapp/rooms) for a detailed guide.
## useWsServer
Server-wide state: all connections, global broadcast, room queries. Available anywhere — including outside event contexts — once a `WooksWs` adapter has been constructed; it throws `[event-ws] No active WooksWs adapter` otherwise. With multiple `WooksWs` instances in one process, it reads the most recently constructed one.
```ts
import { useWsServer } from '@wooksjs/event-ws'
ws.onMessage('admin', '/broadcast', () => {
const { broadcast, connections } = useWsServer()
// Send to ALL connected clients
broadcast('announcement', '/system', { text: 'Server restarting' })
return { totalConnections: connections().size }
})
```
| Method | Signature | Description |
|--------|-----------|-------------|
| `connections` | `() => Map` | All active connections |
| `broadcast` | `(event, path, data?, params?) => void` | Send to ALL connected clients |
| `getConnection` | `(id: string) => WsConnection \| undefined` | Look up a specific connection |
| `roomConnections` | `(room: string) => Set` | All connections in a room |
### Difference from useWsRooms().broadcast
- `useWsServer().broadcast()` sends to **all** connected clients regardless of room membership.
- `useWsRooms().broadcast()` sends only to members of a specific room, excluding the sender by default.
### Accessing connections directly
```ts
const { getConnection, roomConnections } = useWsServer()
// Send to a specific connection by ID
const conn = getConnection('some-connection-id')
conn?.send('notification', '/private', { text: 'Just for you' })
// Iterate members of a room
for (const conn of roomConnections('/chat/general')) {
console.log(conn.id, conn.rooms)
}
```
## useRouteParams
Route parameters work the same as in HTTP. Available in message handlers when the path pattern contains parameters.
```ts
import { useRouteParams } from '@wooksjs/event-ws'
ws.onMessage('message', '/chat/:room', () => {
const { get } = useRouteParams<{ room: string }>()
const room = get('room') // → 'general'
})
```
This is re-exported from `@wooksjs/event-core` for convenience.
## HTTP Composables in WebSocket
When a connection starts as an HTTP upgrade request, the original request data is accessible through the parent context chain. HTTP composables resolve transparently:
::: warning Integrated mode only
HTTP composables are available only when the connection went through an UPGRADE route that calls `ws.upgrade()` (integrated mode with `@wooksjs/event-http`). In standalone mode (`ws.listen()`), there is no HTTP upgrade context and these composables throw.
:::
```ts
import { useHeaders, useCookies, useAuthorization } from '@wooksjs/event-http'
ws.onConnect(() => {
// All of these read from the original upgrade request
const { headers } = useHeaders()
const { getCookie } = useCookies()
const { is, basicCredentials } = useAuthorization()
if (!is('bearer')) {
throw new WsError(401, 'Authentication required')
}
})
ws.onMessage('query', '/me', () => {
// Also works in message handlers — resolved through parent chain
const { headers } = useHeaders()
return { userAgent: headers['user-agent'] }
})
```
| HTTP Composable | Works in WS (integrated mode)? | Notes |
|----------------|-------------|-------|
| `useRequest()` | Yes | Returns the upgrade `IncomingMessage` |
| `useHeaders()` | Yes | Upgrade request headers |
| `useCookies()` | Yes | Cookies from the upgrade request |
| `useAuthorization()` | Yes | Auth header from the upgrade request |
| `useUrlParams()` | Yes | Query string from the upgrade URL |
| `useAccept()` | Yes | Accept header from the upgrade request |
| `useResponse()` | No | No HTTP response exists in WebSocket context |
| `useBody()` | No | Upgrade requests have no body |
## WsError
Throw `WsError` to send structured error responses to the client.
```ts
import { WsError } from '@wooksjs/event-ws'
ws.onMessage('join', '/chat/:room', () => {
const { data } = useWsMessage<{ name: string }>()
if (!data?.name) {
throw new WsError(400, 'Name is required')
}
if (isNameTaken(data.name)) {
throw new WsError(409, 'Name already taken')
}
// ...
})
```
| Context | Behavior |
|---------|---------|
| `onMessage` with `id` | Sends `{ id, error: { code, message } }` |
| `onMessage` without `id` | Nothing sent; `WsError` is silently swallowed (only non-`WsError` exceptions are logged) |
| `onConnect` | Rejects the connection (close code 1008 for 401/403, 1011 for others) |
## Connection Lifecycle
### onConnect
Runs when a new WebSocket connection is established. Use it for authentication, session setup, or logging.
```ts
ws.onConnect(() => {
const { id } = useWsConnection()
const { getCookie } = useCookies()
console.log(`New connection: ${id}`)
// Reject unauthenticated connections
if (!getCookie('session')) {
throw new WsError(401, 'Not authenticated')
}
})
```
### onDisconnect
Runs when a connection closes. Use it for cleanup. Room membership is cleaned up automatically — you don't need to call `leave()` here.
```ts
ws.onDisconnect(() => {
const { id } = useWsConnection()
console.log(`Disconnected: ${id}`)
// Custom cleanup: remove from state maps, notify other users, etc.
})
```
::: warning
`useWsRooms()` is **not available** in `onDisconnect` — it requires a message context. If you need to notify rooms about a disconnection, use `useWsServer().getConnection(id)` to read the connection's `rooms` set and send messages manually.
:::
### Heartbeat
In standalone mode, the server sends periodic `ping` frames to detect dead connections. A connection that has not answered the previous ping with a `pong` by the next tick is closed with WS code `1001` (Heartbeat timeout) — the pong window equals `heartbeatInterval`.
```ts
const ws = createWsApp({
heartbeatInterval: 30000, // ms between pings (default: 30000)
})
await ws.listen(3000)
```
Set `heartbeatInterval: 0` to disable heartbeat.
::: warning
Heartbeat runs only in standalone mode (`ws.listen()`). When `@wooksjs/event-ws` is integrated with an HTTP server via `createWsApp(http)` + `ws.upgrade()`, no heartbeat pings are sent — implement your own liveness checks if you need dead-connection reaping in integrated mode.
:::
## Server Options
All options accepted by `createWsApp()` — pass them as the only argument in standalone mode, or as the second argument after the HTTP app in integrated mode:
| Option | Default | Description |
|--------|---------|-------------|
| `heartbeatInterval` | `30000` | Ms between heartbeat pings (standalone mode only). `0` disables. |
| `messageParser` | `JSON.parse` | Custom deserializer for incoming messages. See [Custom Serialization](/wsapp/protocol#custom-serialization). |
| `messageSerializer` | `JSON.stringify` | Custom serializer for outgoing messages. See [Custom Serialization](/wsapp/protocol#custom-serialization). |
| `maxMessageSize` | 1 MB | Incoming messages exceeding this size are silently dropped. |
| `wsServerAdapter` | wraps `ws` | Plug-in point for alternative WS engines (e.g. uWebSockets.js, Bun). An object implementing `WsServerAdapter` (`create(): WsServerInstance`); the default wraps the `ws` package in `noServer` mode. |
| `broadcastTransport` | local only | Cross-instance broadcast transport. See [Multi-Instance Broadcasting](/wsapp/rooms#multi-instance-broadcasting). |
| `logger` | built-in | Logger instance. See [Logging](/wsapp/logging). |
---
URL: "wooks.moost.org/wsapp/introduction.html"
LLMS_URL: "wooks.moost.org/wsapp/introduction.md"
---
# Introduction to Wooks WebSocket
::: warning Experimental
This package is in an experimental phase. The API may change without following semver until it reaches a stable release.
:::
`@wooksjs/event-ws` is the WebSocket adapter for Wooks. It gives you a routed WebSocket server where every handler is a plain function, and every piece of connection and message data is available through composables — on demand, typed, cached. Pair it with `@wooksjs/ws-client` for a structured, type-safe client.
## Quick Picture
```ts
import { createHttpApp } from '@wooksjs/event-http'
import { createWsApp, useWsMessage, useWsRooms } from '@wooksjs/event-ws'
const http = createHttpApp()
const ws = createWsApp(http) // auto-registers upgrade contract
http.upgrade('/ws', () => ws.upgrade())
ws.onMessage('message', '/chat/:room', () => {
const { data } = useWsMessage<{ text: string }>()
const { broadcast } = useWsRooms()
broadcast('message', data)
})
http.listen(3000)
```
Messages are routed by **event** + **path**, just like HTTP methods + URL. Composables give you the connection, message, room membership, and server-wide state — all without callback parameters.
## What You Get
### Server (`@wooksjs/event-ws`)
| Composable | What it provides |
|------------|-----------------|
| `useWsConnection()` | Connection ID, send push messages, close connection |
| `useWsMessage()` | Typed message payload, raw data, correlation ID, event, path |
| `useWsRooms()` | Join/leave rooms, broadcast to room members |
| `useWsServer()` | All connections, server-wide broadcast, room membership queries |
| `useRouteParams()` | Typed route parameters (from `@wooksjs/event-core`) |
Plus `WsError` for structured error responses, heartbeat keep-alive, and a pluggable broadcast transport for multi-instance deployments (e.g. Redis pub/sub).
### Client (`@wooksjs/ws-client`)
| Method | What it does |
|--------|-------------|
| `send(event, path, data?)` | Fire-and-forget message |
| `call(event, path, data?)` | RPC with automatic correlation — returns a Promise |
| `subscribe(path, data?)` | Subscribe with auto-resubscribe on reconnect |
| `on(event, pathPattern, handler)` | Listen for server push messages (exact or wildcard paths) |
Zero runtime dependencies. Works in browsers and Node.js 22+ out of the box (native `WebSocket`); on Node.js < 22, expose the `ws` package as a global polyfill.
## How It Fits Together
The server and client share a simple JSON wire protocol. The client sends `{ event, path, data?, id? }`, the server routes by `event` + `path`, and replies with `{ id, data? }` or pushes `{ event, path, data? }`. No custom framing, no binary encoding — just JSON over WebSocket.
HTTP composables (`useHeaders()`, `useCookies()`, `useAuthorization()`, etc.) work inside WebSocket handlers too — they read from the original upgrade request through the parent context chain.
## Next Steps
- [Quick Start](/wsapp/) — Set up a WebSocket server and client in minutes.
- [What is Wooks?](/wooks/what) — How composables, context, and `defineWook` work under the hood.
- [Client Guide](/wsapp/client) — Full guide to the browser/Node.js client.
- [Wire Protocol](/wsapp/protocol) — The JSON message format in detail.
---
URL: "wooks.moost.org/wsapp/logging.html"
LLMS_URL: "wooks.moost.org/wsapp/logging.md"
---
---
URL: "wooks.moost.org/wsapp/protocol.html"
LLMS_URL: "wooks.moost.org/wsapp/protocol.md"
---
# Wire Protocol
::: warning Experimental
This package is in an experimental phase. The API may change without following semver until it reaches a stable release.
:::
The server (`@wooksjs/event-ws`) and client (`@wooksjs/ws-client`) communicate using a simple JSON protocol over WebSocket text frames. No custom framing, no binary encoding — just JSON.
[[toc]]
## Message Types
There are three message shapes:
### Client → Server
```ts
interface WsClientMessage {
event: string // Router method (e.g. "message", "join", "subscribe")
path: string // Route path (e.g. "/chat/general")
data?: unknown // Payload
id?: string | number // Correlation ID — present for RPC (call()), absent for fire-and-forget (send())
}
```
### Server → Client: Reply
Sent when the client message included an `id`. Exactly one reply per request.
```ts
interface WsReplyMessage {
id: string | number // Matches the client's correlation ID
data?: unknown // Handler return value
error?: { code: number; message: string } // Present on error, mutually exclusive with data
}
```
### Server → Client: Push
Server-initiated messages: broadcasts, direct sends, subscription notifications.
```ts
interface WsPushMessage {
event: string // Event type
path: string // Concrete path
params?: Record // Route params extracted by server router
data?: unknown // Payload
}
```
## Message Flow
### RPC (call)
```
Client Server
│ │
│ { event: "join", │
│ path: "/chat/general", │
│ data: { name: "Alice" }, │
│ id: 1 } │
│ ──────────────────────────────▶ │
│ │ routes by event + path
│ │ runs handler
│ │
│ { id: 1, │
│ data: { joined: true } } │
│ ◀────────────────────────────── │
```
### Fire-and-forget (send)
```
Client Server
│ │
│ { event: "message", │
│ path: "/chat/general", │
│ data: { text: "Hi!" } } │
│ ──────────────────────────────▶ │
│ │ routes by event + path
│ │ runs handler
│ │ return value ignored (no id)
```
### Push (broadcast)
```
Client A Server Client B
│ │ │
│ { event: "message", │ │
│ path: "/chat/gen", │ │
│ data: { text } } │ │
│ ─────────────────────▶│ │
│ │ { event: "message",│
│ │ path: "/chat/gen",│
│ │ params: { room: "gen" },
│ │ data: { text } } │
│ │────────────────────▶│
```
## Routing
The server routes incoming messages using `event` as the method and `path` as the route pattern, identical to how HTTP uses `GET`/`POST` + URL:
```ts
// Server
ws.onMessage('join', '/chat/:room', handler) // matches event="join", path="/chat/general"
ws.onMessage('message', '/chat/:room', handler) // matches event="message", path="/chat/general"
ws.onMessage('query', '/users/:id', handler) // matches event="query", path="/users/42"
ws.onMessage('echo', '/*', handler) // matches event="echo", any path
```
Route parameters are extracted by the router and available via `useRouteParams()`.
## Error Responses
### Handler error
When a handler throws and the client sent an `id`:
```json
{ "id": 1, "error": { "code": 400, "message": "Name is required" } }
```
### No matching handler
When no handler matches the event + path and the client sent an `id`:
```json
{ "id": 1, "error": { "code": 404, "message": "Not found" } }
```
### Unhandled error
When a non-`WsError` exception is thrown:
```json
{ "id": 1, "error": { "code": 500, "message": "Internal Error" } }
```
If the client didn't send an `id`, nothing is sent to the client; unexpected (non-`WsError`) exceptions are logged server-side, while thrown `WsError` instances are silently swallowed.
## Message Discrimination
The client distinguishes incoming messages by shape:
| Shape | Type | Routed to |
|-------|------|----------|
| Has `id` field | Reply | RPC tracker — resolves or rejects a pending `call()` |
| Has `event` + `path` fields | Push | Push dispatcher — fires matching `on()` handlers |
| Unparseable | — | Silently dropped |
## Edge Cases
| Scenario | Behavior |
|----------|---------|
| Message exceeds `maxMessageSize` (default: 1 MB) | Silently dropped, connection stays open |
| JSON parse failure | Silently dropped |
| No handler matched, no `id` | Silently dropped |
| No handler matched, has `id` | `{ id, error: { code: 404, message: "Not found" } }` |
## Custom Serialization
Both server and client support pluggable serialization for advanced use cases (e.g. MessagePack, CBOR):
```ts
// Server
const ws = createWsApp(http, {
messageParser: myDecode,
messageSerializer: myEncode,
})
// Client
const client = createWsClient(url, {
messageParser: myDecode,
messageSerializer: myEncode,
})
```
Both sides must use the same serialization format.
---
URL: "wooks.moost.org/wsapp/rooms.html"
LLMS_URL: "wooks.moost.org/wsapp/rooms.md"
---
# Rooms & Broadcasting
::: warning Experimental
This package is in an experimental phase. The API may change without following semver until it reaches a stable release.
:::
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]]
## Joining and Leaving
```ts
import { useWsRooms, useWsMessage } from '@wooksjs/event-ws'
ws.onMessage('join', '/chat/:room', () => {
const { join } = useWsRooms()
join() // room = current path, e.g. '/chat/general'
return { joined: true }
})
ws.onMessage('leave', '/chat/:room', () => {
const { leave } = useWsRooms()
leave()
return { left: true }
})
```
### Custom room names
You can pass an explicit room name instead of using the message path:
```ts
ws.onMessage('join', '/teams/:team/channels/:channel', () => {
const { get } = useRouteParams<{ team: string; channel: string }>()
const { join } = useWsRooms()
// Join a room with a custom name
join(`team:${get('team')}:${get('channel')}`)
return { joined: true }
})
```
### Automatic cleanup
When a connection closes, it is automatically removed from all rooms. You don't need to call `leave()` in `onDisconnect`.
### Listing rooms
```ts
const { rooms } = useWsRooms()
console.log(rooms()) // → ['/chat/general', '/chat/random']
```
## Broadcasting
### To a room (from a handler)
`useWsRooms().broadcast()` sends a push message to all members of a room, **excluding the sender** by default.
```ts
ws.onMessage('message', '/chat/:room', () => {
const { data } = useWsMessage<{ text: string; from: string }>()
const { broadcast } = useWsRooms()
// Sends to all room members except the sender
broadcast('message', data)
})
```
The broadcast uses the current message path as the room name by default. The push message's `path` is the room name, and the route params of the currently matched message route (`useRouteParams()`) are attached when non-empty:
```ts
// Path pattern: /chat/:room
// Concrete path: /chat/general
// Client receives: { event: 'message', path: '/chat/general', params: { room: 'general' }, data: ... }
```
### Broadcast options
```ts
broadcast('message', data, {
room: '/chat/random', // override the target room (default: current message path)
excludeSelf: false, // include the sender in the broadcast (default: true)
})
```
When you override the room via `options.room`, the attached params still come from the current message's route match, not from the room string.
### To all connections (server-wide)
Use `useWsServer().broadcast()` to send to every connected client regardless of room membership:
```ts
import { useWsServer } from '@wooksjs/event-ws'
ws.onMessage('admin', '/announce', () => {
const { data } = useWsMessage<{ text: string }>()
const { broadcast } = useWsServer()
// Sends to ALL connected clients
broadcast('announcement', '/announce', data)
return { sent: true }
})
```
### To a specific connection
Use `useWsServer().getConnection()` to send a push message to a single connection:
```ts
const { getConnection } = useWsServer()
const conn = getConnection(targetId)
conn?.send('notification', '/private', { text: 'Hello' })
```
`getConnection()` returns a `WsConnection`:
| Member | Description |
|--------|-------------|
| `id` | Unique connection ID |
| `rooms` | `Set` of joined room names |
| `send(event, path, data?, params?)` | Push a message to this connection |
| `reply(id, data?)` | Send an RPC reply with the given correlation ID |
| `replyError(id, code, message)` | Send an RPC error reply |
| `close(code?, reason?)` | Close the underlying WebSocket |
All sends silently no-op when the socket is not OPEN.
Or from within a handler, use `useWsConnection().send()` to push back to the current connection:
```ts
const { send } = useWsConnection()
send('notification', '/alerts', { text: 'Welcome!' })
```
## Broadcasting from onDisconnect
`useWsRooms()` is not available in `onDisconnect` because there is no message context. To notify rooms about a disconnection, read the connection's rooms manually:
```ts
ws.onDisconnect(() => {
const { id } = useWsConnection()
const { getConnection, roomConnections } = useWsServer()
const connection = getConnection(id)
if (!connection) return
for (const room of connection.rooms) {
for (const member of roomConnections(room)) {
if (member.id !== id) {
member.send('system', room, { text: `User ${id} disconnected` })
}
}
}
})
```
## Room Queries
Query room membership from any handler:
```ts
import { useWsServer } from '@wooksjs/event-ws'
ws.onMessage('query', '/api/rooms', () => {
const { roomConnections } = useWsServer()
return {
general: roomConnections('/chat/general').size,
random: roomConnections('/chat/random').size,
}
})
```
## Multi-Instance Broadcasting
By default, rooms are local to a single Node.js process. For horizontal scaling (multiple server instances behind a load balancer), provide a `WsBroadcastTransport`:
```ts
import { createWsApp } from '@wooksjs/event-ws'
const ws = createWsApp(http, {
broadcastTransport: myRedisTransport,
})
```
The transport interface:
```ts
interface WsBroadcastTransport {
publish(channel: string, payload: string): void | Promise
subscribe(channel: string, handler: (payload: string) => void): void | Promise
unsubscribe(channel: string): void | Promise
}
```
When a transport is provided:
- `join()` subscribes to the channel `ws:room:` when the first local connection joins the room
- `broadcast()` publishes to the channel; other instances receive and forward to their local connections
- `leave()` / disconnect unsubscribes when the last local connection leaves a room
::: warning Transport must not redeliver own messages
Local members are served directly before the publish. Your transport must **not** redeliver messages published by the same instance — plain Redis pub/sub redelivers to the publishing process, causing every local member to receive each broadcast twice. Wrap payloads with an instance ID and skip inbound messages whose instance ID matches your own.
:::
The room registry itself is the exported `WsRoomManager` class (`join`, `leave`, `leaveAll`, `connections`, `broadcast`) — useful for custom setups and tests. It manages the room → connections mapping and the transport channel subscriptions described above.
---
URL: "wooks.moost.org/wsapp/testing.html"
LLMS_URL: "wooks.moost.org/wsapp/testing.md"
---
# Testing
::: warning Experimental
This package is in an experimental phase. The API may change without following semver until it reaches a stable release.
:::
`@wooksjs/event-ws` provides test context utilities that let you run handler logic in isolation — without a real WebSocket connection or HTTP server.
[[toc]]
::: warning Adapter required for some composables
`useWsConnection()`, `useWsRooms()` and `useWsServer()` require a constructed `WooksWs` adapter even in tests — construct one once in test setup:
```ts
import { beforeAll } from 'vitest'
import { createWsApp } from '@wooksjs/event-ws'
beforeAll(() => {
createWsApp()
})
```
On a test context, only `id`, `close` and `context` of `useWsConnection()` are usable — `send()` resolves the connection from the adapter's connection map, which does not contain the mock test connection.
:::
## Test Utilities
### prepareTestWsMessageContext
Creates a message context runner for testing `onMessage` handlers. Returns a function that executes a callback inside the context.
```ts
import { prepareTestWsMessageContext, useWsMessage, useWsConnection } from '@wooksjs/event-ws'
const run = prepareTestWsMessageContext({
event: 'message',
path: '/chat/general',
data: { text: 'hello', from: 'Alice' },
messageId: 42,
})
run(() => {
const { data, id, path, event } = useWsMessage<{ text: string; from: string }>()
expect(data.text).toBe('hello')
expect(data.from).toBe('Alice')
expect(id).toBe(42)
expect(path).toBe('/chat/general')
expect(event).toBe('message')
})
```
#### Options
```ts
interface TTestWsMessageContext {
event: string // Required: event type
path: string // Required: message path
data?: unknown // Message payload
messageId?: string | number // Correlation ID
rawMessage?: Buffer | string // Raw message data
id?: string // Connection ID (default: 'test-conn-id')
params?: Record // Route params
parentCtx?: EventContext // Optional parent (HTTP) context
}
```
### prepareTestWsConnectionContext
Creates a connection context runner for testing `onConnect` / `onDisconnect` handlers.
```ts
import { prepareTestWsConnectionContext, useWsConnection } from '@wooksjs/event-ws'
const run = prepareTestWsConnectionContext({
id: 'test-connection-123',
})
run(() => {
const { id } = useWsConnection()
expect(id).toBe('test-connection-123')
})
```
#### Options
```ts
interface TTestWsConnectionContext {
id?: string // Connection ID (default: 'test-conn-id')
params?: Record // Route params
parentCtx?: EventContext // Optional parent (HTTP) context
}
```
## Testing with Route Params
```ts
const run = prepareTestWsMessageContext({
event: 'message',
path: '/chat/general',
data: { text: 'hello' },
params: { room: 'general' },
})
run(() => {
const { get } = useRouteParams<{ room: string }>()
expect(get('room')).toBe('general')
})
```
## Testing with HTTP Parent Context
To test composables that read from the HTTP upgrade request (like `useCookies()`, `useHeaders()`), provide a parent context:
```ts
import { current } from '@wooksjs/event-core'
import { prepareTestHttpContext, useCookies } from '@wooksjs/event-http'
import { prepareTestWsMessageContext } from '@wooksjs/event-ws'
// Create an HTTP context to act as the parent
const httpRun = prepareTestHttpContext({
url: '/ws',
method: 'GET',
headers: { cookie: 'session=abc123' },
})
httpRun(() => {
// Create a WS message context with the current (HTTP) context as parent
const wsRun = prepareTestWsMessageContext({
event: 'query',
path: '/me',
parentCtx: current(),
})
wsRun(() => {
// HTTP composables resolve through the parent chain
const { getCookie } = useCookies()
expect(getCookie('session')).toBe('abc123')
})
})
```
## Example: Testing a Message Handler
```ts
import { describe, it, expect } from 'vitest'
import { prepareTestWsMessageContext, useWsMessage, WsError } from '@wooksjs/event-ws'
// The handler under test
function joinHandler() {
const { data } = useWsMessage<{ name: string }>()
if (!data?.name) {
throw new WsError(400, 'Name is required')
}
return { joined: true, name: data.name }
}
describe('join handler', () => {
it('returns joined status', () => {
const run = prepareTestWsMessageContext({
event: 'join',
path: '/chat/general',
data: { name: 'Alice' },
messageId: 1,
})
const result = run(() => joinHandler())
expect(result).toEqual({ joined: true, name: 'Alice' })
})
it('throws on missing name', () => {
const run = prepareTestWsMessageContext({
event: 'join',
path: '/chat/general',
data: {},
messageId: 2,
})
expect(() => run(() => joinHandler())).toThrow(WsError)
})
})
```