Recommended reading: The request context problem and Backend composables — the context model and composable pattern this article builds on.
Most backend frameworks are HTTP frameworks that pretend otherwise.
They might support WebSockets as a plugin. CLI tooling lives in a separate package. Scheduled jobs get wired through a queue library with its own conventions. And if the same business action needs to run from an API endpoint and a CLI command and a scheduled job, you write three wrappers around the same logic.
Not because the logic is different. Because the framework assumed HTTP from the start.
That is the problem I hit after building the composables layer from the previous article. The composables worked great inside HTTP handlers — but the moment I needed the same action from a CLI command or a workflow step, the HTTP-shaped context leaked everywhere.
The duplication nobody talks about
A pattern most teams know but rarely name.
You build an API endpoint that generates a report:
app.post('/api/reports/generate', async () => {
const user = await useCurrentUser().requireUser()
const tenant = useTenant().current()
const filters = useReportFilters().value()
return generateReport({ user, tenant, filters })
})
Then someone needs the same report from a CLI tool for backfills. Then ops wants it as a scheduled job. Then a workflow step needs it too.
Each entry point gets its own wrapper: different context setup, different argument parsing, different error handling. The business logic is the same — everything around it is duplicated.
The usual fix is to extract a "service" and wire context manually per transport. That works, but it pushes context management back to the developer — exactly the problem composables were supposed to solve.
Transport is an edge concern
The insight behind Wooks: transport should be an edge concern, not the main architecture.
HTTP, CLI, WebSocket, workflow — just different ways an event enters the system. Once it arrives, the app needs the same things: identity, permissions, configuration, scoped state, logging.
Wooks adapters are thin edge layers that translate transport-specific input into a shared event context. Everything else — context creation, composable resolution — lives in one core.
One core, many adapters
Here is what the wiring looks like:
import { createHttpApp } from '@wooksjs/event-http'
import { createCliApp } from '@wooksjs/event-cli'
import { createWsApp } from '@wooksjs/event-ws'
import { createWfApp } from '@wooksjs/event-wf'
const http = createHttpApp()
const cli = createCliApp({}, http) // shares the same Wooks instance
const ws = createWsApp(http) // shares it too
const wf = createWfApp({}, http) // and so does the workflow adapter
Each adapter registers handlers in its own namespace — HTTP on requests, CLI on parsed commands, WebSocket on messages, workflows on step triggers. But they all resolve composables like useRouteParams(), useEventId(), and useLogger() from the shared core.
HTTP, CLI, and workflows side by side
The same report from three entry points.
HTTP:
http.post('/reports/generate', async () => {
const user = await useCurrentUser().requireUser()
const filters = useReportFilters().value()
return generateReport({ user, filters })
})
CLI:
cli.cli('reports generate', {
description: 'Generate a report from the command line',
options: [
{ keys: ['format', 'f'], description: 'Output format', value: 'pdf' },
],
handler: async () => {
const user = await useServiceAccount().get()
const filters = useReportFilters().value()
const format = useCliOption('format')
const report = await generateReport({ user, filters })
return writeReport(report, format)
},
})
Workflow step:
wf.step('generate-report', {
handler: async () => {
const { ctx } = useWfState()
const filters = useReportFilters().value()
ctx().report = await generateReport({
user: ctx().user,
filters,
})
},
})
generateReport is the same function in all three cases. What changes is only what should change: how the user is identified, how arguments arrive, how output is delivered.
Each adapter brings its own composables
Each adapter adds transport-specific composables on top of the shared core:
HTTP (Wooks HTTP stack):
-
useRequest(),useCookies(),useAuthorization(),useUrlParams(),useBody()
CLI (@wooksjs/event-cli):
-
useCliOptions(),useCliOption(),useCliHelp(),useAutoHelp()
WebSocket (@wooksjs/event-ws):
-
useWsConnection(),useWsMessage(),useWsRooms()
Workflows (@wooksjs/event-wf):
-
useWfState()— access to workflow context, step input, resume state
Same pattern everywhere: call a composable, get a scoped value, no singletons, no manual threading.
Context flows through parent chains
Wooks event contexts support parent chains.
When a WebSocket connection starts from an HTTP upgrade, the WS context inherits the HTTP context. When a message arrives, it creates a child context under the connection.
HTTP upgrade context
└── WS connection context
└── WS message context
A composable in a message handler can read HTTP-level data (like the original auth header) through the parent chain — no manual wiring.
Workflows work the same way when you pass eventContext: current() from an HTTP handler. That link is explicit, not automatic, so workflows stay independent when triggered from CLI or scheduled jobs.
Scoped context with read-through inheritance. Not magic.
Where this pays off
Admin CLIs share permission logic with the API. Same composables, same rules — no separate auth setup.
Data repair scripts skip the boilerplate. A backfill CLI runs the same composables with a service account instead of reimplementing context from scratch.
Scheduled jobs need zero glue. The workflow adapter creates an event context, so cron-triggered steps call the same functions the API uses.
Live dashboards reuse data access. A WebSocket view shares composables with the REST API and adds room-based broadcasting through the WS adapter.
The pattern goes beyond built-in adapters
Wooks ships with HTTP, CLI, WebSocket, and workflow adapters. But the event core is not limited to those four.
Any event source — a message queue, a file watcher, a custom protocol — can be wrapped into a Wooks adapter. The adapter creates an event context, feeds it into the shared core, and from that point on, all composables just work. Same lazy resolution, same scoping, same caching.
The four built-in adapters are starting points, not boundaries. Wooks is not "another HTTP framework." It is an event-processing core, and HTTP is just one of its adapters.
Some teams want more structure
This lower-level power is valuable. But not every team wants to wire adapters and define composables from scratch.
Many teams want controllers, decorators, dependency injection — the kind of conventions that make onboarding fast and code predictable.
That is where Moost comes in — a higher-level framework built on Wooks that adds all of that without losing the composable event core underneath.
Read next: Moost without the ceremony — NestJS ergonomics without the module tax. Controllers, DI, and guards that start simple.



Top comments (0)