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.
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",)Handle an event
Section titled “Handle an event”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"]Options
Section titled “Options”| 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. |
Handler context
Section titled “Handler context”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
applybefore you register the handler, and capture them in its closure.
Event.once
Section titled “Event.once”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.
Dispatch an event
Section titled “Dispatch an event”| 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")Removal and cancellation during dispatch
Section titled “Removal and cancellation during dispatch”| 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.
Middleware
Section titled “Middleware”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>"Where each part runs
Section titled “Where each part runs”| 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
nextand processes the result. - Adding middleware, or providing values around
next, does not change the context oflast. - Only the context is restored. Execution does not move to a different fiber, and downstream work is not detached from cancellation.
The next function
Section titled “The next function”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.
Recognize a dispatch origin
Section titled “Recognize a dispatch origin”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. |
Realms and filters
Section titled “Realms and filters”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.