Skip to content

Action

Source: backend/classes/Action.ts

The Action class is the foundation of bun-actionhero. Every controller — whether it handles HTTP, WebSocket, CLI, or background tasks — is an action. You write the logic once, and the framework handles the transport plumbing.

Class Definition

ts
abstract class Action {
  /** Unique identifier, e.g. "user:create" */
  name: string;

  /** Human-readable description — shows up in CLI --help and Swagger */
  description?: string;

  /** Zod schema for input validation */
  inputs?: z.ZodType<any>;

  /** Middleware to run before/after this action */
  middleware?: ActionMiddleware[];

  /** HTTP routing — route can be a string with :params or a RegExp */
  web?: {
    route: RegExp | string;
    method: HTTP_METHOD;
  };

  /** Background task config — queue is required, frequency makes it recurring */
  task?: {
    frequency?: number;
    queue: string;
  };

  /**
   * The handler. Return data to send to the client.
   * Throw TypedError for error responses.
   */
  abstract run(
    params: ActionParams<Action>,
    connection?: Connection,
  ): Promise<any>;
}

How Actions Work Across Transports

This is the core idea. You define an action once — its name, inputs, and run() method — and the framework routes it through every transport automatically. The same Zod validation, the same middleware chain, the same run() method, the same response shape. The only thing that changes is how the request arrives and how the response is delivered.

Here's what a single action looks like from each transport:

HTTP

Add a web property to expose an action as an HTTP endpoint. The web server matches incoming requests by route and method, extracts params from the URL path, query string, and request body, validates them against the Zod schema, and calls run().

ts
export class UserCreate implements Action {
  name = "user:create";
  web = { route: "/user", method: HTTP_METHOD.PUT };
  inputs = z.object({
    name: z.string().min(3),
    email: z.string().email(),
    password: secret(z.string().min(8)),
  });

  async run(params: ActionParams<UserCreate>) {
    // ...
    return { user: serializeUser(user) };
  }
}
bash
curl -X PUT http://localhost:8080/api/user \
  -H "Content-Type: application/json" \
  -d '{"name":"Evan","email":"evan@example.com","password":"secret123"}'
# → { "user": { "id": 1, "name": "Evan", ... } }

Params are loaded in this order (later sources override earlier ones): path params → URL query params → request body. Routes support :param path parameters (/user/:id) and RegExp patterns.

WebSocket

WebSocket clients send JSON messages with messageType: "action", the action name, and params. The server finds the matching action, validates params through the same Zod schema, and sends the response back over the socket.

json
// Client sends:
{
  "messageType": "action",
  "action": "user:create",
  "messageId": "abc-123",
  "params": {
    "name": "Evan",
    "email": "evan@example.com",
    "password": "secret123"
  }
}

// Server responds:
{
  "messageId": "abc-123",
  "response": { "user": { "id": 1, "name": "Evan" } }
}

The messageId is echoed back so the client can match responses to requests. WebSocket connections are long-lived — they maintain session state and can subscribe to channels for real-time PubSub.

CLI

Every action is automatically registered as a CLI command via Commander. The Zod schema's field names become --flags, descriptions become help text, and required vs optional fields are enforced.

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

# → { "response": { "user": { "id": 1, "name": "Evan", ... } } }

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

bash
./actionhero.ts "user:create" --help

The server boots in CLI mode — initializers that don't apply (like the web server) are skipped based on their runModes setting.

Background Tasks

Add a task property to schedule an action as a background job. The Resque worker calls run() with the same params and validation — the action doesn't know or care whether it was triggered by HTTP, a cron schedule, or a fan-out parent.

ts
export class MessagesCleanup implements Action {
  name = "messages:cleanup";
  task = { queue: "default", frequency: 1000 * 60 * 60 }; // hourly
  inputs = z.object({
    age: z.coerce.number().default(1000 * 60 * 60 * 24),
  });

  async run(params: ActionParams<MessagesCleanup>) {
    // same run() — called by the task worker, not by HTTP
    return { messagesDeleted: deleted.length };
  }
}

See Tasks for fan-out patterns and queue configuration.

HTTP_METHOD

ts
enum HTTP_METHOD {
  GET = "GET",
  POST = "POST",
  PUT = "PUT",
  DELETE = "DELETE",
  PATCH = "PATCH",
  OPTIONS = "OPTIONS",
}

Type Helpers

These two types are used throughout the codebase — in actions, tests, ops, and on the frontend:

ts
/** Infers the validated input type from an action's Zod schema */
type ActionParams<A extends Action> =
  A["inputs"] extends z.ZodType<any>
    ? z.infer<A["inputs"]>
    : Record<string, unknown>;

/** Infers the return type of an action's run() method */
type ActionResponse<A extends Action> = Awaited<ReturnType<A["run"]>> &
  Partial<{ error?: TypedError }>;

ActionResponse includes an optional error field because the framework catches TypedError throws and adds them to the response automatically. The frontend imports ActionResponse<MyAction> to get type-safe API responses without any code generation step.

ActionMiddleware

Middleware intercepts action execution. Both methods are optional — you can have auth-only middleware (just runBefore) or logging-only middleware (just runAfter):

ts
type ActionMiddleware = {
  runBefore?: (
    params: ActionParams<Action>,
    connection: Connection,
  ) => Promise<ActionMiddlewareResponse | void>;
  runAfter?: (
    params: ActionParams<Action>,
    connection: Connection,
  ) => Promise<ActionMiddlewareResponse | void>;
};

type ActionMiddlewareResponse = {
  /** Replace the params before the action runs */
  updatedParams?: ActionParams<Action>;
  /** Replace the response after the action runs */
  updatedResponse?: any;
};

Throw from runBefore to halt execution — the action's run() method won't be called. Return updatedParams or updatedResponse to modify the data flowing through the pipeline.

Middleware runs in the same order regardless of transport. HTTP, WebSocket, CLI, tasks — same middleware chain, same behavior.

Released under the MIT License.