Skip to content

bun-actionheroThe Modern TypeScript API Framework

Write one action. It handles HTTP, WebSocket, CLI, and background tasks. Built on Bun, powered by Zod, backed by Redis and Postgres.

ActionHero mascot

Write Once, Run Everywhere

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.

bun-actionhero

Test

What is this Project?

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.

One Action, Every Transport

Here's what that looks like in practice. This is one action:

ts
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:

HTTPPUT /api/user with JSON body, query params, or form data:

bash
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:

json
{ "messageType": "action", "action": "user:create",
  "params": { "name": "Evan", "email": "evan@example.com", "password": "secret123" } }

CLI — flags are generated from the Zod schema automatically:

bash
./actionhero.ts "user:create" --name Evan --email evan@example.com --password secret123 -q | jq

Background Task — enqueued to a Resque worker via Redis:

ts
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.

Key Components

  • Transport-agnostic Actions — HTTP, WebSocket, CLI, and background tasks from one class
  • Zod input validation — type-safe params with automatic error responses and OpenAPI generation
  • Built-in background tasks via node-resque, with a fan-out pattern for parallel job processing
  • Strongly-typed frontend integrationActionResponse<MyAction> gives the frontend type-safe API responses, no code generation needed
  • Drizzle ORM with auto-migrations (replacing the old ah-sequelize-plugin)
  • Companion Next.js frontend as a separate application (replacing ah-next-plugin)

Why Bun?

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:

  • Native TypeScript — no compilation step
  • Built-in test runner
  • Module resolution that just works
  • Fast startup and an excellent packager
  • fetch included natively — great for testing

Project Structure

  • root — a slim package.json wrapping the backend and frontend workspaces. bun install and bun dev work here, but you need to cd into each workspace for tests.
  • backend — the ActionHero server
  • frontend — the Next.js application
  • docs — the documentation site

Local Development

Install dependencies (macOS):

bash
# 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 bun

Install packages:

bash
bun install

Set environment variables:

bash
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
# update as needed

Run in development:

bash
bun dev

Both the frontend and backend hot-reload when files change.

Test:

bash
# 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.ts

Lint:

bash
bun lint     # check
bun format   # fix

Production Builds

bash
bun compile
# set NODE_ENV=production in .env
bun start

Databases and Migrations

We use Drizzle as the ORM. Migrations are derived from schemas — edit your schema files in schema/*.ts, then generate and apply:

bash
cd backend && bun run migrations
# restart the server — pending migrations auto-apply

Actions, CLI Commands, and Tasks

Unlike 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.

Web Actions

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:

ts
web = { route: "/user/:id", method: HTTP_METHOD.GET };

WebSocket Actions

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.

CLI Actions

Enabled by default. Every action is registered as a CLI command via Commander. The Zod schema generates --flags and --help text automatically:

bash
./actionhero.ts "user:create" --name evan --email "evantahler@gmail.com" --password password -q | jq

The -q flag suppresses logs so you get clean JSON. Use --help on any action to see its params.

Task Actions

Add a task property to schedule an action as a background job. A queue is required, and frequency is optional for recurring execution:

ts
task = { queue: "default", frequency: 1000 * 60 * 60 }; // every hour

Fan-Out Tasks

A 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 form
  • api.actions.fanOut(jobs[], options?) — multi-action form, each job specifies { action, inputs?, queue? }
  • api.actions.fanOutStatus(fanOutId) — returns { total, completed, failed, results, errors }
  • Options: batchSize (default 100), resultTtl (default 600s)

Example — single action:

ts
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:

ts
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.

Marking Secret Fields

Mark sensitive fields with secret() so they're redacted as [[secret]] in logs:

ts
inputs = z.object({
  email: z.string().email(),
  password: secret(z.string().min(8)),
});

Intentional Changes from ActionHero

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.

Production Deployment

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.

Documentation

Full docs at bun.actionherojs.com, including:

Released under the MIT License.