ablauf

Workflow engine for Nuxt
1,458
Install
npm install ablauf
Latest Version:0.1.1
Vue:3
License:MIT
Last Updated:Sep 22, 2026
Links: npm
Maintainer: niki2k1

Ablauf

npm version npm downloads License Nuxt

A Nuxt module for defining state machines with typed states, directional transitions, and pluggable handler pipelines.

Features

  • Define workflows as JSON with states, transitions, and directions
  • Transition handler pipelines — run validation, confirmation, or side-effects before a state change
  • Generic conditions bag on transitions for custom domain logic (e.g. nativeOnly, billed)
  • Pluggable storage via WorkflowProvider — built-in file provider (bundles JSON into the server build at compile time, works across every Nitro deploy preset), or bring your own (database, API, etc.)
  • useWorkflow composable with getNextStates, findTransition, transition, and more
  • executeTransition server util with server/transitions/ guard and effect handlers
  • Auto-imported types, composables, and server utilities
  • Hot-reload for workflow definitions and transition handlers in dev

Quick Setup

Install the module:

npx nuxt module add ablauf

Add it to your nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['ablauf'],

  workflow: {
    provider: 'file',               // 'file' (default) or 'custom'
    workflowsDir: 'server/workflows', // where JSON definitions live
    exposeApi: true,                 // register /api/_workflow routes
  },
})

Defining a Workflow

Create a JSON file in your workflowsDir:

// server/workflows/default.json
{
  "name": "default",
  "description": "Issue Tracker",
  "states": [
    { "slug": "backlog",     "name": "Backlog",     "color": "#6366F1", "category": "start" },
    { "slug": "todo",        "name": "Todo",        "color": "#3B82F6", "category": "in-progress" },
    { "slug": "in-progress", "name": "In Progress", "color": "#F59E0B", "category": "in-progress" },
    { "slug": "review",      "name": "Review",      "color": "#8B5CF6", "category": "in-progress" },
    { "slug": "done",        "name": "Done",        "color": "#10B981", "category": "end" }
  ],
  "transitions": [
    { "from": "backlog", "to": "todo", "direction": "forward" },
    { "from": "todo", "to": "in-progress", "direction": "forward",
      "handler": [{ "name": "permission", "params": { "permission": "start-work" } }] },
    { "from": "in-progress", "to": "review", "direction": "forward" },
    { "from": "review", "to": "done", "direction": "forward",
      "handler": [{ "name": "confirm", "params": { "message": "Mark as done?" } }] },
    { "from": "review", "to": "in-progress", "direction": "backward" },
    { "from": "todo", "to": "backlog", "direction": "backward" }
  ]
}

Each state has a category (start, in-progress, or end) that controls behavior: catch-all from: "all" rules never apply from an end state, but a rule that names the end state explicitly (a reopen) still does.

Using the Composable

useWorkflow is auto-imported and fetches a workflow by name:

<script setup>
const workflow = await useWorkflow('default')

// Get all states
workflow.getStates()

// Get possible next states from a given state
workflow.getNextStates('todo', 'forward')
// => ['in-progress']

// Find a specific transition rule
workflow.findTransition('todo', 'in-progress')

// Execute a transition (runs handler pipeline)
const result = await workflow.transition('todo', 'in-progress', {
  issue: currentIssue,
})
// Returns the TransitionRule on success, or false if a handler blocked it
</script>

Pure Graph Core

The graph logic behind the composable lives in ablauf/runtime/core and has no DOM, fetch or handler dependencies, so the same functions answer "which transitions are legal" on the client and on the server. All take the workflow definition as first argument:

import { getNextStates, findTransition, isEndState } from 'ablauf/runtime/core'

getNextStates(workflow, 'todo', 'forward')      // => ['in-progress']
findTransition(workflow, 'todo', 'in-progress') // => TransitionRule | undefined
isEndState(workflow, 'done')                    // => true

They are auto-imported unprefixed in server routes (getNextStates(...)) and with a workflow prefix on the client (workflowGetNextStates(...)) so they don't clash with the composable's methods. Use them server-side to validate an incoming transition instead of trusting the client.

Transition Handlers

Handlers are TypeScript files in app/transitions/ that run during a transition. They can validate, prompt the user, or enrich the transition args.

// app/transitions/confirm.ts
export default defineTransitionHandler({
  name: 'confirm',
  friendlyName: 'Confirm Action',
  description: 'Asks the user to confirm before proceeding.',
  run: async (params, _args) => {
    const message = (params?.message as string) ?? 'Are you sure?'
    if (!window.confirm(message)) {
      return false // blocks the transition
    }
  },
})

Handlers are referenced by name in the workflow JSON:

{ "from": "review", "to": "done", "direction": "forward",
  "handler": [{ "name": "confirm", "params": { "message": "Mark as done?" } }] }

Multiple handlers run in sequence. A handler can:

  • Return false to block the transition
  • Return an object to merge data into the args for subsequent handlers
  • Return nothing to allow the transition to proceed

Set global: true on a handler to run it on every transition automatically.

Custom Workflow Provider

The built-in file provider reads JSON from disk. For database-backed workflows, use the custom provider:

// nuxt.config.ts
export default defineNuxtConfig({
  workflow: {
    provider: 'custom',
  },
})

Then register your provider in a Nitro plugin:

// server/plugins/workflow.ts
export default defineNitroPlugin(() => {
  setWorkflowProvider({
    async getWorkflow(name) {
      return await db.workflows.findOne({ name })
    },
    async listWorkflows() {
      return await db.workflows.findMany()
    },
    // Optional: enable write operations
    async saveWorkflow(workflow) { /* ... */ },
    async deleteWorkflow(name) { /* ... */ },
  })
})

setWorkflowProvider and useWorkflowProvider are auto-imported in server routes.

Server-Side Execution

The client composable only collects input. Whether a transition is legal is decided on the server with executeTransition, which is auto-imported in server routes. It is persistence-agnostic: you load the current state, let ablauf decide, and write the result yourself inside your own transaction.

// server/api/tickets/[id]/transition.post.ts
export default defineEventHandler(async (event) => {
  const { to, input } = await readBody(event)
  const session = await requireUserSession(event)
  const ticket = await loadTicket(event)
  const workflow = await useWorkflowProvider().getWorkflow('default')

  return db.transaction(async (tx) => {
    const result = await executeTransition(workflow, {
      state: ticket.status,
      to,
      input,
      actor: { id: session.user.id, roles: session.user.roles },
      ctx: { ticket, db: tx }, // passed through to handlers untouched
    })
    if (!result.ok) {
      throw createError({ statusCode: 409, message: result.reason })
    }
    await tx.update(tickets).set({ status: result.state }).where(eq(tickets.id, ticket.id))
    return result
  })
})

The result is either { ok: true, state, transition, data, messages, next } or { ok: false, code, reason } with code one of same-state, no-transition, blocked (a guard said no) or invalid-input (a handler's input schema rejected the payload).

Server Transition Handlers

Handlers in server/transitions/ share their name with the client handlers in app/transitions/: the client half opens a dialog and returns input, the server half validates and acts. Either half is optional; a name that only exists on the client is skipped by executeTransition.

// server/transitions/permission.ts
export default defineServerTransitionHandler({
  name: 'permission',
  friendlyName: 'Check Permission',
  phase: 'guard', // runs before any effect, must not mutate
  run: async (params, args) => {
    if (!args.actor?.roles?.includes(params?.permission as string)) {
      throw new TransitionHandlerError('Missing permission')
    }
  },
})
// server/transitions/resolve.ts
import { z } from 'zod'

export default defineServerTransitionHandler({
  name: 'resolve',
  friendlyName: 'Set resolution',
  input: z.object({ resolution: z.string().min(1) }), // any Standard Schema library
  run: async (_params, args) => {
    await args.ctx.db.update(tickets).set({ resolution: args.input.resolution })
    args.messages.push(`Resolved as ${args.input.resolution}`)
    return { resolution: args.input.resolution } // merged into result.data
  },
})
  • phase is guard or effect (default). Guards run first, effects run in definition order inside the caller's transaction.
  • global: true runs the handler on every transition, before the transition's own handlers.
  • Returning false or throwing TransitionHandlerError blocks the transition; any other error propagates so the caller's transaction rolls back.
  • Handlers never open their own database connection. Everything they need arrives via ctx.

GET /api/_workflow/handlers lists the server handlers with name, description and phase for editor UIs.

Typed Conditions

Transitions support a generic conditions bag for domain-specific filtering. Define your conditions shape, then pass a conditionFilter that decides which transitions are available based on runtime context:

interface MyConditions {
  role?: string
  feature?: string
}

const workflow = await useWorkflow<MyConditions>('default', {
  conditionFilter: (rule, context) => {
    if (rule.conditions?.role && rule.conditions.role !== context.userRole) return false
    if (rule.conditions?.feature && !context.enabledFeatures?.includes(rule.conditions.feature)) return false
    return true
  },
})

// Only transitions matching the current context are returned
workflow.getNextStates('todo', 'forward', {
  userRole: 'admin',
  enabledFeatures: ['beta'],
})

In the workflow JSON, attach conditions to any transition:

{ "from": "review", "to": "done", "direction": "forward",
  "conditions": { "role": "admin" } }

API Routes

When exposeApi is enabled (default), two routes are registered:

Route Description
GET /api/_workflow List all workflows
GET /api/_workflow/:name Get a workflow by name

Contribution

Local development
# Install dependencies
npm install

# Generate type stubs
npm run dev:prepare

# Develop with the playground
npm run dev

# Build the playground
npm run dev:build

# Run ESLint
npm run lint

# Run Vitest
npm run test
npm run test:watch

# Release new version
npm run release

Version History

Version Released Vue License
0.1.1 ^3.0 MIT
0.1.0 ^3.0 MIT
0.0.6 ^3.0 MIT
0.0.4 ^3.0 MIT
0.0.2 ^3.0 MIT