One Action, Every Transport
Write your controller once — it works as an HTTP endpoint, WebSocket handler, CLI command, and background task simultaneously.
Write one action. It handles HTTP, WebSocket, CLI, and background tasks. Built on Bun, powered by Zod, backed by Redis and Postgres.
One action class. It's your HTTP endpoint, your CLI command, your WebSocket handler, and your background task — all at the same time.
export class Status implements Action {
name = "status";
description = "Return the status of the server";
inputs = z.object({});
web = { route: "/status", method: HTTP_METHOD.GET };
task = { queue: "default", frequency: 60000 };
async run() {
return {
name: api.process.name,
uptime: Date.now() - api.bootTime,
};
}
}That's it. Same validation, same error handling, same response shape — whether the request comes from a browser, a CLI, or a cron job.
This is a modern rewrite of ActionHero, built on Bun. I still believe in the core ideas behind ActionHero — it was an attempt to take the best ideas from Rails and Node.js and shove them together — but the original framework needed a fresh start with modern tooling.
The big idea: write your controller once, and it works everywhere. A single action class handles HTTP requests, WebSocket messages, CLI commands, and background tasks — same inputs, same validation, same middleware, same response. No duplication.
Here's what that looks like in practice. This is one action:
export class UserCreate implements Action {
name = "user:create";
description = "Create a new user";
inputs = z.object({
name: z.string().min(3),
email: z.string().email(),
password: secret(z.string().min(8)),
});
web = { route: "/user", method: HTTP_METHOD.PUT };
task = { queue: "default" };
async run(params: ActionParams<UserCreate>) {
const user = await createUser(params);
return { user: serializeUser(user) };
}
}That one class gives you:
HTTP — PUT /api/user with JSON body, query params, or form data:
curl -X PUT http://localhost:8080/api/user \
-H "Content-Type: application/json" \
-d '{"name":"Evan","email":"evan@example.com","password":"secret123"}'WebSocket — send a JSON message over an open connection:
{ "messageType": "action", "action": "user:create",
"params": { "name": "Evan", "email": "evan@example.com", "password": "secret123" } }CLI — flags are generated from the Zod schema automatically:
./actionhero.ts "user:create" --name Evan --email evan@example.com --password secret123 -q | jqBackground Task — enqueued to a Resque worker via Redis:
await api.actions.enqueue("user:create", { name: "Evan", email: "evan@example.com", password: "secret123" });Same validation, same middleware chain, same run() method, same response shape. The only thing that changes is how the request arrives and how the response is delivered.
ActionResponse<MyAction> gives the frontend type-safe API responses, no code generation neededah-sequelize-plugin)ah-next-plugin)TypeScript is still the best language for web APIs. But Node.js has stalled — Bun is moving faster and includes everything we need out of the box:
fetch included natively — great for testingpackage.json wrapping the backend and frontend workspaces. bun install and bun dev work here, but you need to cd into each workspace for tests.Install dependencies (macOS):
# install bun
curl -fsSL https://bun.sh/install | bash
# install postgres and redis
brew install postgresql redis
brew services start postgresql
brew services start redis
# create a database
createdb bunInstall packages:
bun installSet environment variables:
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
# update as neededRun in development:
bun devBoth the frontend and backend hot-reload when files change.
Test:
# one-time setup
createdb bun-test
# full CI — lint + test both apps
bun run ci
# single test file
cd backend && bun test __tests__/actions/user.test.tsLint:
bun lint # check
bun format # fixbun compile
# set NODE_ENV=production in .env
bun startWe use Drizzle as the ORM. Migrations are derived from schemas — edit your schema files in schema/*.ts, then generate and apply:
cd backend && bun run migrations
# restart the server — pending migrations auto-applyUnlike the original ActionHero, we've removed the distinction between actions, CLI commands, and tasks. They're all the same thing now. You can run any action from the CLI, schedule any action as a background task, and call any action via HTTP or WebSocket. Same input validation, same responses, same middleware.
Add a web property to expose an action as an HTTP endpoint. Routes support :param path parameters and RegExp patterns — the route lives on the action itself, no separate routes.ts file:
web = { route: "/user/:id", method: HTTP_METHOD.GET };Enabled by default. Clients send JSON messages with { messageType: "action", action: "user:create", params: { ... } }. The server validates params through the same Zod schema and sends the response back over the socket. WebSocket connections also support channel subscriptions for real-time PubSub.
Enabled by default. Every action is registered as a CLI command via Commander. The Zod schema generates --flags and --help text automatically:
./actionhero.ts "user:create" --name evan --email "evantahler@gmail.com" --password password -q | jqThe -q flag suppresses logs so you get clean JSON. Use --help on any action to see its params.
Add a task property to schedule an action as a background job. A queue is required, and frequency is optional for recurring execution:
task = { queue: "default", frequency: 1000 * 60 * 60 }; // every hourA parent task can distribute work across many child jobs using api.actions.fanOut(). This is useful for tasks like "process all users" that need to fan out to individual worker jobs.
Queue Priority: Workers drain queues left-to-right. Configure queues: ["worker", "scheduler"] so child jobs on "worker" are processed before parent jobs on "scheduler".
API:
api.actions.fanOut(actionName, inputsArray, queue?, options?) — single-action formapi.actions.fanOut(jobs[], options?) — multi-action form, each job specifies { action, inputs?, queue? }api.actions.fanOutStatus(fanOutId) — returns { total, completed, failed, results, errors }batchSize (default 100), resultTtl (default 600s)Example — single action:
export class ProcessAllUsers implements Action {
name = "users:processAll";
task = { frequency: 1000 * 60 * 60, queue: "scheduler" };
async run() {
const users = await getActiveUsers();
const result = await api.actions.fanOut(
"users:processOne",
users.map(u => ({ userId: u.id })),
"worker",
);
return { fanOut: result };
}
}
// Child — no special fan-out code needed
export class ProcessOneUser implements Action {
name = "users:processOne";
task = { queue: "worker" };
inputs = z.object({ userId: z.string() });
async run(params) { /* process one user */ }
}Example — multiple actions:
const result = await api.actions.fanOut([
{ action: "users:processOne", inputs: { userId: "1" } },
{ action: "users:processOne", inputs: { userId: "2" } },
{ action: "emails:send", inputs: { to: "a@b.com" }, queue: "priority" },
]);
const status = await api.actions.fanOutStatus(result.fanOutId);
// → { total: 3, completed: 3, failed: 0, results: [...], errors: [...] }Fan-out results are stored in Redis with a configurable TTL (default 10 minutes), refreshed on each child completion.
Mark sensitive fields with secret() so they're redacted as [[secret]] in logs:
inputs = z.object({
email: z.string().email(),
password: secret(z.string().min(8)),
});Unified Controllers — Actions, tasks, and CLI commands are the same thing. One class, configured for each transport via properties.
Separate Applications — Frontend and backend are separate Bun applications. Deploy them independently — frontend on Vercel, backend on a VPS, whatever works.
Routes on Actions — Actions define their own routes (strings with :params or RegExp). No routes.ts file.
Real-Time Channels — PubSub via Redis with middleware-based authorization for WebSocket clients.
Simplified Logger — No Winston. STDOUT and STDERR only, with optional colors and timestamps.
No Pidfiles — Process management is left to your deployment tooling.
Environment Config — Config is static at boot, with per-NODE_ENV overrides via environment variables (e.g., DATABASE_URL_TEST is used automatically when NODE_ENV=test).
Middleware — Applied to actions as an array of ActionMiddleware objects with runBefore and runAfter hooks. Can throw to halt, or return modified params/responses.
Testing — No mock server. Make real HTTP requests with fetch — Bun includes it natively.
Drizzle ORM — First-class database support with auto-migrations and type-safe schemas.
Sessions — Cookie-based sessions stored in Redis, a first-class part of the API.
No Cache Layer — The old ActionHero cache has been removed. Use Redis directly — it's already part of the stack.
Each application has its own Dockerfile, and a docker-compose.yml runs them together. You probably won't use this exact setup in production, but it shows how the pieces fit together.
Full docs at bun.actionherojs.com, including: