---
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
if (!useAutoHelp()) {
// fallback to useCommandLookupHelp if command help was not found
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.
## 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
## 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).
For more details, explore [Routing](/cliapp/routing), [Options](/cliapp/options), and [Help Generation](/cliapp/cli-help).
---
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' },
],
aliases: ['root'],
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` you'll see the usage instructions:
```bash
node your-script.js root --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) => {
// Whenever cli command was not recognized by router
// this callback will be called
if (!useAutoHelp()) {
// Display command lookup help if command help was not found
useCommandLookupHelp()
// Raise a standard error when the command is not recognized
raiseError()
}
},
});
```
If `--help` isn't present, `useCommandLookupHelp()` suggests similar commands. If nothing matches, `raiseError()` throws the standard "unknown command" error.
---
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.
---
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 th 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.
---
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!
```
## 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).
## Next Steps
- **Working with Request:** Check out [Request Composables](/webapp/composables/request) to learn how to manipulate request data, read headers or cookies and more.
- **Add Logging & Error Handling:** Integrate event loggers or custom error-handling composables to make debugging easier.
- **Advance to Other Flavors:** Once comfortable with HTTP, consider exploring 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());
}
});
```
## 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, formerly radix3):** Fast for static lookups. Weaker on complex dynamic patterns — regex constraints and multi-segment wildcards are not supported.
**Wooks ([@prostojs/router](https://github.com/prostojs/router)):** Categorizes routes into statics, parameters, and wildcards with indexing and caching. Supports features the others don't:
- 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((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 methods live on one object. No separate `useSetHeaders()`, `useSetCookies()`, `useSetCacheControl()` — just `useResponse()`.
## 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, 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.
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, 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!
```
## 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)
} = useRequest()
const body = await rawBody() // Body as a Buffer
})
```
## 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.
## 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). 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 |
### 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.
## 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. `Readable` stream (you must specify `Content-Type` yourself)
5. `fetch` `Response` (streams body to client response)
## 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 |
## 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` |
## 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())
app.use(express.json())
// Wooks handles these routes
const wooks = new WooksExpress(app)
wooks.get('/api/users', () => {
return [{ id: 1, name: 'Alice' }]
})
// Express handles this route
app.get('/legacy', (req, res) => {
res.send('handled by express')
})
app.listen(3000)
```
## 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 |
### 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.
```ts
await wooks.close()
```
## Available Composables
These come from `@wooksjs/event-http` and work inside any Wooks handler:
| Composable | Purpose |
| -------------------- | ------------------------------------------- |
| `useRequest()` | Request method, URL, headers, body, IP |
| `useRouteParams()` | Route parameters (`:id`, etc.) |
| `useHeaders()` | Request headers |
| `useResponse()` | Set status, headers, cookies, cache control |
| `useCookies()` | Read request cookies |
| `useUrlParams()` | URL query parameters |
| `useAuthorization()` | Parse Authorization header |
| `useAccept()` | Check Accept header |
| `useLogger()` | Event-scoped logger |
See the [Composables](/webapp/composables/) section for full reference.
---
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 })
```
## 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 |
### 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
These come from `@wooksjs/event-http` and work inside any Wooks handler:
| Composable | Purpose |
| -------------------- | ------------------------------------------- |
| `useRequest()` | Request method, URL, headers, body, IP |
| `useRouteParams()` | Route parameters (`:id`, etc.) |
| `useHeaders()` | Request headers |
| `useResponse()` | Set status, headers, cookies, cache control |
| `useCookies()` | Read request cookies |
| `useUrlParams()` | URL query parameters |
| `useAuthorization()` | Parse Authorization header |
| `useAccept()` | Check Accept header |
| `useLogger()` | Event-scoped logger |
See the [Composables](/webapp/composables/) section for full reference.
---
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:
- **Cookie propagation** to the parent SSR response does not work — cookies written via `writeHead` are captured on the inner response but not auto-propagated. Use `response.setCookie()` instead for SSR-compatible cookie handling.
- **`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)
})
)
server.listen(3000)
```
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'
})
```
## 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 |
| `defaultHeaders` | `Record` | — | Headers added to every response |
| `requestLimits` | `object` | — | Request body size limits (`maxCompressed`, `maxInflated`) |
### 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)
```
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
These come from `@wooksjs/event-http` and work inside any Wooks handler:
| Composable | Purpose |
| -------------------- | ------------------------------------------- |
| `useRequest()` | Request method, URL, headers, body, IP |
| `useRouteParams()` | Route parameters (`:id`, etc.) |
| `useHeaders()` | Request headers |
| `useResponse()` | Set status, headers, cookies, cache control |
| `useCookies()` | Read request cookies |
| `useUrlParams()` | URL query parameters |
| `useAuthorization()` | Parse Authorization header |
| `useAccept()` | Check Accept header |
See the [Composables](/webapp/composables/) section for full reference.
---
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.
---
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.
## Restrict cookies/headers to pass
You can restrict the cookies and headers that are passed in the proxy request by specifying
the `reqCookies` and `reqHeaders` options in the `useProxy` function.
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 = proxy('https://mayapi.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 `useProxy` function provides 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
// 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.
---
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]]
## 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
router.get('/api/vars/:optionalKey?', () => 'ok')
// Optional wildcard
router.get('/api/vars/:*?', () => 'ok')
// Several optional parameters
router.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 object = 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(
userPathBuilder({
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', options);
});
```
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`: An object containing additional headers to add to the response.
- `cacheControl`: The Cache-Control header value for caching control. You can provide a string or an object with cache control directives.
- `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.
- `defaultExt`: The default file extension to be added to the file path if no file extension is provided.
- `listDirectory`: A boolean value indicating whether to list files in a directory if the file path corresponds to a directory.
- `index`: The filename of the index file to automatically serve from the folder if present.
## 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.
---
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()
```
## 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
- [Steps](/wf/steps) — handlers, parametric routing, composables, string handlers
- [Flows](/wf/flows) — conditions, loops, subflows, break/continue
- [Input & Resume](/wf/input-and-resume) — pause workflows for user input, resume later
---
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)) |
| `useWfOutlet()` | `@wooksjs/event-wf` | Access outlet infrastructure (state strategy, outlet registry) |
| `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.
## Conditional Steps
Attach a `condition` to skip a step when the condition is false. Conditions are string expressions evaluated against the workflow context:
```ts
app.flow('process-order', [
'calculate-total',
{ id: 'apply-discount', condition: 'total > 100' },
'charge-payment',
])
```
`apply-discount` only runs if `context.total > 100`.
## 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 before the first step, inside the workflow context:
```ts
app.flow('report', ['gather-data', 'format', 'send'], '', async () => {
const { ctx } = useWfState()
const context = ctx()
context.startedAt = Date.now()
context.reportId = await generateId()
})
```
Use `init` to set up derived context values or run async setup before the flow starts. 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.stepResult // return value of the last executed step
output.resume?.(input) // shortcut to resume the flow
output.retry?.() // shortcut to retry a failed step
```
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.
---
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.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' }
```
## Hardcoding Input in Flows
If you know the input value ahead of time, provide it in the flow schema to skip the pause:
```ts
app.flow('auto-signup', [
{ id: 'ask-name', input: 'System User' },
{ id: 'ask-email', input: 'system@internal.dev' },
{ id: 'ask-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
```
## 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 app.resume(output.state)
// or use the shortcut:
const retried = await output.retry()
}
```
You can also provide new input when retrying, or add delay/backoff logic in your application code before calling `resume()`.
## 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.
---
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('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, token consumption (single-use for email, reusable for HTTP), 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 { input } = useWfState()
if (input()) return // already provided on resume
return outletHttp({ fields: ['email'] }) // pause and ask client
},
})
wf.step('save', {
handler: () => {
const { ctx, input } = useWfState()
const data = input<{ email: string }>()
if (data) ctx<{ email?: string }>().email = data.email
},
})
wf.flow('signup', ['ask-email', 'save'])
// 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 }
```
## 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',
async deliver(request, token) {
await smsService.send(request.target!, `Your code: ${token}`)
return { response: { sent: true } }
},
}
```
## Configuration
The `handleWfOutletRequest` function (or the handler returned by `createOutletHandler`) accepts a `WfOutletTriggerConfig`:
```ts
interface WfOutletTriggerConfig {
state: WfStateStrategy | ((wfid: string) => WfStateStrategy)
outlets: WfOutlet[]
token?: {
name?: string // default: 'wfs'
read?: Array<'body' | 'query' | 'cookie'> // default: ['body', 'query', 'cookie']
write?: 'body' | 'cookie' // default: 'body'
consume?: boolean | Record // default: { email: true }
}
wfidName?: string // default: 'wfid'
allow?: string[]
block?: string[]
initialContext?: (body, wfid) => unknown
onFinished?: (ctx: { context, schemaId }) => unknown
}
```
### State Strategies
State strategies control how workflow state is persisted between pause and resume.
**`HandleStateStrategy`** — server-side storage with a short handle/UUID as token:
```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, encrypted token (no server storage needed):
```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
})
```
The entire workflow state is encrypted into the token itself using AES-256-GCM.
### 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
**`token.consume`** — controls single-use vs reusable tokens per outlet:
```ts
// Default: email tokens are consumed, HTTP tokens are reusable
{ token: { consume: { email: true } } }
// All tokens are single-use:
{ token: { consume: true } }
// All tokens are reusable:
{ token: { consume: false } }
```
When a token is consumed, it is invalidated after the first resume — preventing replay attacks on email magic links.
### 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 { getStateStrategy, 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
verified?: boolean
}
const wf = createWfApp()
wf.step('collect-email', {
handler: () => {
const { input } = useWfState()
if (input()) return
return outletHttp({ fields: ['email'], title: 'Enter your email' })
},
})
wf.step('send-verification', {
handler: () => {
const { ctx, input } = useWfState()
const data = input<{ email: string }>()
if (data) {
ctx().email = data.email
return // resume after email link clicked
}
return outletEmail(ctx().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: "token1", input: { email: "user@test.com" } }` → sends verification email
3. User clicks `GET /signup?wfs=token2` → workflow completes, redirect to `/welcome`
---
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++
},
})
```
## 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() // id of the current 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) and `input` (the step input) available. They cannot access Node.js APIs, imports, or composables.
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 app.resume(output.state)
// or shortcut:
// const retried = await output.retry()
}
```
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('review', {
input: 'approval',
handler: () => {
const { ctx, input } = useWfState()
ctx<{ approved: boolean }>().approved = input() ?? false
},
})
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)
---
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 set global logger options when creating the Wooks application. These options control:
- **Logging Level:** Determines which log messages are displayed (e.g., `fatal`, `error`, `warn`, `log`, `info`, `debug`).
- **Transports:** Functions or utilities that handle the output of log messages. By default, logs go to the console, but you can add custom transports to write to files, external services, etc.
**Example (HTTP app):**
```ts
import { createHttpApp } from '@wooksjs/event-http'
const app = createHttpApp({
logger: {
topic: 'my-app',
level: 2, // Only fatal and error logs will show globally
transports: [(log) => console.log(`[${log.topic}][${log.type}] ${log.timestamp}`, ...log.messages)],
},
})
```
**Example (CLI app):**
```ts
import { createCliApp } from '@wooksjs/event-cli'
const app = createCliApp({
logger: {
topic: 'my-cli',
level: 4, // Show up to info level
},
})
```
Logger options are 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[])`
The `level` number controls which of these methods produce output. For instance, a level of `2` allows `fatal` (0) and `error` (1) logs only.
## 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'] })
})
```
## 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`.
---
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
```
**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
})
```
### `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
```
### `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
```
## 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.
## 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)
```
## 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): (ctx?: EventContext) => T
```
## 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 don't have a great answer for this:
- **Express / Fastify**: everything goes on `req`. No type safety, no structure. Every middleware mutates a shared object — `req.user`, `req.body`, `req.parsedQuery` — and you cross your fingers it's all there by the time your handler runs.
- **h3**: a cleaner `event` object — better. But extending it type-safely? Building lazy, cached properties on it? No built-in tools for that. You're back to ad-hoc code.
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.
- **[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-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
```
The server requires `@wooksjs/event-http` because WebSocket connections start as HTTP upgrade requests. 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
```ts
import WebSocket from 'ws'
import { createWsClient } from '@wooksjs/ws-client'
const client = createWsClient('ws://localhost:3000/ws', {
_WebSocket: WebSocket as any, // [!code hl]
rpcTimeout: 5000,
})
```
The only difference is passing the `ws` package as `_WebSocket`. Everything else works the same.
## 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.
## Next Steps
- [Composables](/wsapp/composables) — Full reference for all server composables.
- [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.
---
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
```
For Node.js, 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
```ts
import WebSocket from 'ws'
import { createWsClient } from '@wooksjs/ws-client'
const client = createWsClient('ws://localhost:3000/ws', {
_WebSocket: WebSocket as any,
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)
_WebSocket?: typeof WebSocket // WebSocket constructor override (Node.js)
}
```
## 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. All composables follow the Wooks `defineWook` pattern — results are cached per context and resolved lazily.
[[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.
## 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 in **any** context — connection handlers, message handlers, or even outside of them if you have a reference to the adapter.
```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
Because WebSocket connections start as HTTP upgrade requests, the original request data is accessible through the parent context chain. HTTP composables resolve transparently:
```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? | 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` | Error is logged, nothing sent |
| `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
The server sends periodic `ping` frames to detect dead connections. Connections that don't respond with `pong` are terminated.
```ts
const ws = createWsApp(http, {
heartbeatInterval: 30000, // ms between pings (default: 30000)
})
```
Set `heartbeatInterval: 0` to disable heartbeat.
---
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 (native `WebSocket`) and Node.js (with `ws` package).
## 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`, errors are logged server-side but nothing is sent to the client.
## 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 server automatically extracts route params from the room path and includes them in the push message:
```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)
})
```
### 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' })
```
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:`
- `broadcast()` publishes to the channel; all instances receive and forward to their local connections
- `leave()` / disconnect unsubscribes when the last local connection leaves a room
---
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]]
## 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 { prepareTestHttpContext } from '@wooksjs/event-http'
import { prepareTestWsMessageContext, useWsMessage } from '@wooksjs/event-ws'
import { useCookies } from '@wooksjs/event-http'
// Create an HTTP context to act as the parent
const httpRun = prepareTestHttpContext({
url: '/ws',
method: 'GET',
headers: { cookie: 'session=abc123' },
})
httpRun((httpCtx) => {
// Create a WS message context with the HTTP context as parent
const wsRun = prepareTestWsMessageContext({
event: 'query',
path: '/me',
parentCtx: httpCtx,
})
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)
})
})
```