Skip to content

Events

An event is a typed token. Its type fixes three things:

  • The arguments of a dispatch.
  • The result of each handler.
  • The errors that handlers may fail with.
events.ts
import { Option } from "effect"
import { Event } from "@graftland/graft"
// Handlers take a message and return nothing.
export const Message = Event.make<[text: string]>("app/message")
// Handlers take a command and may answer it.
export const Command = Event.make<[name: string], Option.Option<string>>(
"app/command",
)

Event.on registers a handler until the current scope closes. Inside apply, that means until the plugin stops:

import { Effect, Ref } from "effect"
import { Event, Graft, Plugin } from "@graftland/graft"
import { Message } from "./events.ts"
const heard = await Effect.runPromise(
Effect.gen(function* () {
const texts = yield* Ref.make<ReadonlyArray<string>>([])
const listener = Plugin.make({
name: "app/listener",
apply: () =>
Event.on(Message, (text) => Ref.update(texts, (all) => [...all, text])),
})
const fork = yield* Graft.plugin(listener)
yield* fork.await
yield* Event.emit(Message, "Hello")
yield* fork.dispose
yield* Event.emit(Message, "Bye")
return yield* Ref.get(texts)
}).pipe(Effect.scoped, Effect.provide(Graft.layer)),
)
heard // => ["Hello"]
Option What it does
order Lower numbers run first and outermost; default 0. Equal orders retain registration order, with prepend first. Use distinct orders when relative order matters.
prepend Runs before the handlers registered earlier.
global Receives the event from every realm, and ignores dispatch filters.

A handler runs with the context of the code that registered it, not the code that dispatched the event.

  • What it sees. Its own plugin’s services, realm and intercepts.
  • Scope per call. Each call gets its own Scope, which Graft installs after it restores that context. Concurrent calls have independent scopes.
  • What the scope owns. Resources the handler acquires, scoped fibers and child forks belong to that call. Graft releases them when the call completes, after any cancellation cleanup has finished.
  • Longer-lived resources. Acquire them in the activation’s apply before you register the handler, and capture them in its closure.

Event.once registers a handler that runs at most once.

  • Graft removes it before it runs, so it does not run again, even if it dispatches the same event.
  • The running handler stays owned until its execution and cancellation cleanup finish. Then Graft releases its registration; it does not wait for the owner’s scope to close.
Function Calls handlers Result
Event.emit One after another Stops at the first failure.
Event.parallel All at once Every result; all failures combined in one Cause.
Event.settle All at once The Exit of each handler that ran; a failing handler does not fail the dispatch.
Event.bail One after another The first Some a handler returns.
Event.waterfall As a chain of middleware The output of the chain.
import { Effect, Option } from "effect"
import { Event, Graft } from "@graftland/graft"
import { Command } from "./events.ts"
const status = Effect.gen(function* () {
yield* Event.on(Command, () => Effect.succeedNone)
yield* Event.on(Command, (name) => Effect.succeedSome(`${name}: running`))
yield* Event.on(Command, () => Effect.succeedSome("never asked"))
return yield* Event.bail(Command, "status")
})
await Effect.runPromise(status.pipe(Effect.scoped, Effect.provide(Graft.layer))) // => Option.some("status: running")
Situation Result
A handler is removed during dispatch It is not called afterwards.
The handler’s owner stops The interruption skips an ordinary handler; middleware follows the rule below.
A handler interrupts itself or is cancelled Its full Cause goes through the dispatch, and so do cleanup defects.
Event.parallel is cancelled It waits for the cleanup of every call, then combines the interruption with the failures and defects that already happened, including cleanup failures.

If release of a framework-owned Scope is interrupted, Graft reports a ReleaseInterrupted defect whose cause retains the original interruption. This keeps release failures visible through nested events and background tasks; ordinary cancellation remains an interruption.

Event.middleware defines an event whose handlers wrap each other. Each handler receives the input and next, and may:

  • change the input,
  • change the output, or
  • answer without calling next.

waterfall ends the chain with last:

import { Effect, String } from "effect"
import { Event, Graft } from "@graftland/graft"
const Render = Event.middleware<string, string>("app/render")
const render = Effect.gen(function* () {
yield* Event.on(Render, (input, next) =>
Effect.map(next(String.trim(input)), (output) => `<p>${output}</p>`),
)
return yield* Event.waterfall(Render, " hi ", (input) =>
Effect.succeed(String.toUpperCase(input)),
)
})
await Effect.runPromise(render.pipe(Effect.scoped, Effect.provide(Graft.layer))) // => "<p>HI</p>"
Part Context it runs with
Registered middleware Its registration context.
The last callback The dispatcher’s: services, references and log annotations captured when waterfall started.

For last:

  • It gets a fresh invocation Scope, whether or not middleware is present, so it may acquire scoped resources without the dispatcher providing a scope.
  • Its resources close after its execution and cancellation cleanup, before control returns to the calling middleware.
  • Middleware resources stay alive while it awaits next and processes the result.
  • Adding middleware, or providing values around next, does not change the context of last.
  • Only the context is restored. Execution does not move to a different fiber, and downstream work is not detached from cancellation.

The next of each step executes downstream once.

  • Its first execution selects the input. Later executions, including concurrent ones, share the same success, failure or interruption.
  • A skipped handler continues through that same next.
  • Unloading a handler’s owner never replays downstream work that already ran.
  • If the first downstream execution was interrupted, later calls get that interruption instead of starting again.

Unloading a running middleware interrupts the chain, whether it is waiting before next or post-processing its result. Downstream failures are preserved; a downstream success never bypasses post-processing. A handler removed before it is claimed is skipped.

The dispatcher can cancel its wait when next runs in an independently owned fiber. The call still interrupts; it never returns a result that bypasses the middleware. The external owner remains responsible for that fiber’s cleanup and failures.

An origin is an opaque token for recognizing a dispatch chain without sharing the caller’s services.

import { Effect } from "effect"
import { Event, Graft } from "@graftland/graft"
const origin = Event.makeOrigin()
const Changed = Event.make<[], boolean>("@acme/example/Changed")
const check = Effect.gen(function* () {
yield* Event.on(Changed, () => Event.isWithin(origin))
return yield* Event.parallel(Changed).pipe(Event.within(origin))
})
await Effect.runPromise(check.pipe(Effect.scoped, Effect.provide(Graft.layer))) // => [true]
Operation Rule
Event.makeOrigin() Creates a fresh token; another token never matches it.
Event.within(origin) Marks the wrapped effect and its nested dispatches; existing origins remain active.
Event.isWithin(origin) Returns only whether this exact token is active in the current fiber.
Scope and isolation Origins follow the effect, not handler registration. Handler services and realms stay unchanged; no caller Context is exposed.
Trust boundary Tokens cannot be forged. Membership is carried in a context reference; code that provides that reference can change isWithin answers. This is re-entrancy detection, not an authorization boundary.

Events use the same realms as services. A dispatch reaches the handlers that were registered in its own realm for that event. Put the event in a fork’s isolate option to give that part of the tree its own handlers.

For finer control, Event.filtered adds a filter to a dispatch. The filter receives the context that each handler was registered in, so it can check any service or reference there:

import { Context, Effect, Ref } from "effect"
import { Event, Graft } from "@graftland/graft"
import { Message } from "./events.ts"
const Formal = Context.Reference<boolean>("app/Formal", {
defaultValue: () => false,
})
const formalOnly = Event.emit(Message, "Hello").pipe(
Event.filtered((owner) => Context.get(owner, Formal)),
)
const heard = await Effect.runPromise(
Effect.gen(function* () {
const names = yield* Ref.make<ReadonlyArray<string>>([])
const listen = (name: string) =>
Event.on(Message, () => Ref.update(names, (all) => [...all, name]))
yield* listen("casual")
yield* listen("formal").pipe(Effect.provideService(Formal, true))
yield* formalOnly
return yield* Ref.get(names)
}).pipe(Effect.scoped, Effect.provide(Graft.layer)),
)
heard // => ["formal"]

Handlers registered with global: true skip both realms and filters.