Skip to content

Lifecycle Hooks

Hooks let you run code before and after each activity in your pipeline, declared in a separate object keyed by activity name. They’re the place for cross-cutting concerns — work that applies around many activities but doesn’t belong inside any of them: validating inputs, injecting resources, timing, auditing, enforcing invariants.

Because the hooks live in their own object, you can swap the whole set (a no-op set in production, an instrumented set in tests) without touching a single activity.

  • Validate and guard. A hook that throws aborts the pipeline — so a before$ hook is a clean place to assert preconditions before an activity runs, or an after$ hook to verify the result.
  • Inject resources. Attach a database handle, HTTP client, auth token, or config to the context in before$ so the activity itself stays pure and testable.
  • Measure. Stamp a start time in before$, record the duration in after$ — per activity, without cluttering the activity.
  • Audit. after$ receives both the pre-activity context and the result, so you can record what each step changed.
  • Set up and tear down. Optional setup/cleanup methods run once around a pipe() batch for resource lifecycle.
  • Observe. Logging and metrics, the classic case.

Three things govern everything below — verify these once and the patterns fall out naturally:

  • before$<activity>(context) receives the context about to enter the activity (one argument).
  • after$<activity>(before, after) receives two arguments: the context as it entered the activity, and whatever the activity’s operation returned.
  • Hooks are mutation-only. A hook’s return value is ignored. To affect the pipeline, mutate the context object in place (or throw).
  • Throwing aborts the run. An error from any hook propagates as a Sass error and stops the pipeline (after done() runs).

There are two ways to attach hooks, depending on your environment.

Pre-instantiated hooks (Node.js and browser)

Section titled “Pre-instantiated hooks (Node.js and browser)”

Pass a hooks instance to withHooks(). This works everywhere:

import {ActionBuilder, ActionRunner} from "@gesslar/actioneer"
class MyActionHooks {
constructor({debug}) {
this.debug = debug
}
async before$prepare(context) {
this.debug("About to prepare", context)
}
async after$prepare(context) {
this.debug("Finished preparing", context)
}
}
const hooks = new MyActionHooks({debug: console.log})
class MyAction {
setup(builder) {
builder
.withHooks(hooks)
.do("prepare", ctx => { ctx.count = 0; return ctx })
.do("work", ctx => { ctx.count += 1; return ctx })
}
}
const runner = new ActionRunner(new ActionBuilder(new MyAction()))
await runner.pipe([{}], 4)

In Node.js you can load hooks from a module by path with withHooksFile(), passing the file and the exported class name:

import {ActionBuilder, ActionRunner} from "@gesslar/actioneer"
class MyAction {
setup(builder) {
builder
.withHooksFile("./hooks/MyActionHooks.js", "MyActionHooks")
.do("prepare", ctx => { ctx.count = 0; return ctx })
.do("work", ctx => { ctx.count += 1; return ctx })
}
}
const runner = new ActionRunner(new ActionBuilder(new MyAction()))
await runner.pipe([{}], 4)

A hooks class exposes methods (or arrow-function fields) named with the convention event$activityName, where event is before or after. Optional setup and cleanup methods run once around a pipe() batch.

hooks/DocHooks.js
export class DocHooks {
// Runs once before any items are processed — async, can fetch or open
// resources. Throwing here aborts the run before it starts.
setup = async() => {
this.client = await openApiClient()
}
// before$ receives the context entering the activity. Mutate it in place to
// inject resources or normalize input.
before$render = ctx => {
ctx.client = this.client // inject — keeps the activity pure
}
// after$ receives (before, after): the pre-activity context and whatever the
// activity returned. Mutate the result to post-process it.
after$render = (before, result) => {
if(Array.isArray(result?.sections))
result.sections = result.sections.map(s => s.trim())
}
// Runs once after all items finish — close what setup opened.
cleanup = async() => {
await this.client?.close()
}
}

Hooks can be async; the runner awaits them. Their return value is ignored — mutate the context/result, or throw to abort.

Activity names are normalized into method names. The whole name is lowercased, non-word characters are stripped, and words are split on spaces and camelCased — the first word stays lowercase.

Activity nameHook methods
"do work"before$doWork / after$doWork
"step-1"before$step1 / after$step1
"Prepare Data"before$prepareData / after$prepareData
"formatFunction"before$formatfunction / after$formatfunction

Only the hooks you define run — there’s no requirement to cover every activity.

By default, each hook has a 1-second (1000ms) timeout. If a hook exceeds it, the pipeline throws a Sass error. Configure the timeout when constructing ActionHooks directly:

import {ActionHooks} from "@gesslar/actioneer"
new ActionHooks({
actionKind: "MyActionHooks",
hooksFile: "./hooks.js",
hookTimeout: 5000, // 5 seconds
debug: console.log,
})

These are the kinds of things hooks are actually for — drawn from real pipelines that parse and format documentation, where hooks adapt stock activities without forking them.

An after$ hook can clean up what an activity produced — strip fields you don’t want downstream, trim whitespace, drop empties:

export class Parse {
// After tags are extracted, drop the @example tag entirely
after$extractTags = ctx => {
delete ctx.tag?.example
}
}

Fetch or open a resource once in setup, hand it to each activity through the context in before$. The activity never touches credentials or network setup, so it stays trivial to test. setup throwing aborts the run before any work begins:

export class Format {
setup = async() => {
const {status, jokes, error} = await fetchJokes()
if(status === "error")
throw new Error(`Failed to fetch jokes: ${error}`)
this.jokes = jokes
}
// Enrich each function's description before it is formatted
before$formatFunction = ctx => {
const joke = this.jokes.pop()
if(joke && ctx.description)
ctx.description.push("", joke)
}
}

after$ receives the activity’s return value as its second argument. Mutate it in place to transform output — here, rewriting Markdown code fences into MediaWiki syntax after a “format function” activity runs:

export class Format {
after$formatFunction = (ctx, result) => {
if(!Array.isArray(result?.formatted))
return
result.formatted = result.formatted.map(section =>
section.replace(
/```c\n([\s\S]+?)```/g,
"<syntaxhighlight lang=\"c\">\n$1</syntaxhighlight>\n",
),
)
}
}

Because a throwing hook aborts the pipeline, before$ is a natural precondition check — fail fast before an expensive or irreversible activity runs:

export class PaymentHooks {
before$charge = ctx => {
if(!(ctx.amount > 0))
throw new Error(`charge amount must be positive, got ${ctx.amount}`)
}
}

An after$ on a final activity can push the result somewhere — upload to a wiki, write to a bucket, notify a service — using a resource opened in setup:

export class Format {
setup = async() => {
const {BASE_URL, BOT_USERNAME, BOT_PASSWORD} = process.env
this.wiki = await login({BASE_URL, BOT_USERNAME, BOT_PASSWORD})
}
after$finalize = async(ctx, result) => {
await this.wiki.createOrEditPage({
title: result.moduleName,
content: result.content,
})
}
}

When you nest builders (for loops or parallel sections), the parent’s hooks are automatically passed to all children. Hook behavior stays consistent throughout the entire pipeline hierarchy — you configure them once at the top.

See the ActionHooks reference for the full constructor options.