architecture hookmanager - Drowbe/coffee-pub-blacksmith GitHub Wiki
Audience: Blacksmith contributors. Consumer API: see api-hookmanager — how to use HookManager lives there and is not repeated here.
Implementation: scripts/manager-hooks.js (~550 lines). This document describes how it works and why; it deliberately does not copy the code. Line numbers are anchors — if one drifts, trust the file.
HookManager is a thin orchestration layer over Foundry's Hooks. It owns registration, priority ordering, dedupe, throttle/debounce, and batch cleanup. It holds no business logic.
When NOT to use it: the Foundry lifecycle hooks init, setup, and ready fire once during boot, before or while Blacksmith is still assembling itself. Register those directly with Hooks.once(...). HookManager is for recurring, cleanup-bearing hooks.
registerHook takes a single destructured object (:33) — { name, description, priority, callback, options, key, context }. Key order is irrelevant.
Two static Maps (:9-10):
| Map | Shape | Purpose |
|---|---|---|
hooks |
hookName → { hookId, callbacks: [], registeredAt } |
One entry per hook name, holding an array of callback records |
contexts |
context → Set(callbackId) |
Reverse index for batch cleanup |
One Hooks.on per hook name, not per callback (:62-96). The first registration for a name creates a single hookRunner wrapper and stores its hookId; later registrations for that name just append to callbacks[]. This is why several modules can register the same hook without evicting each other.
A callback record (:124-137) carries: callbackId, callback (the wrapped function), description, priority, registeredAt, options, key, context, teardown.
The context field on the record is load-bearing: it is also tracked in the contexts map, but the stats readers (BlacksmithAPIHookStats, hook details) read it off the record, so a callback record that omits context makes the context breakdown collapse to a single default bucket.
The wrapper at :78-123:
-
Snapshot —
entry.callbacks.slice(). Iterate a copy, becauseonceremoval mutates the array mid-dispatch. -
Per-callback
try/catch— one throwing callback logs and does not abort the others. -
onceis deferred — matching ids are collected during the loop and removed in afinallyafter it, never during. Thefinallymatters: cancellation returns from the loop, and an early return that skipped this cleanup would leak aonceregistration that then fires forever. -
Opt-in cancellation — a callback's
falseis honoured only when its registration declaredcanCancel: true(:105). It then stops the chain and the runner returnsfalse. An undeclaredfalseis inert.
Why cancellation is opt-in. One Foundry handler serves every callback registered against a hook name, so whatever the wrapper returns speaks for all of them. Honouring any false meant a callback whose natural return value was a boolean — (doc) => this.tracked.has(doc.id) — cancelled the operation world-wide for every module, silently, on a hook nobody thought of as cancellable. The name-prefix test that gated it was a guess at intent; the declaration replaces the guess, which is also why the prefix test is gone. Whether false means anything at all remains Foundry's decision: Hooks.call honours it, Hooks.callAll ignores it.
Priorities are 1–5, default 3, ascending (1 runs first). Ties break by registeredAt, so order is stable within a level.
The sort happens on insert (:142), not on dispatch. hookRunner depends on this — it only slice()s. Any new code path that mutates callbacks[] must preserve the ordering invariant.
Nothing validates the incoming value. A hook registered at priority: 50 is accepted, sorts last, and is then invisible to showHookDetails(), which only walks 1..5 (:408).
Both are inlined in registerHook (:105-122), wrapping callback into finalCallback.
-
throttleMs— leading edge, timestamp comparison. No timer, so no teardown needed. -
debounceMs— trailing edgesetTimeout. Setsteardown = () => clearTimeout(timeout).
teardown is invoked by removeCallback (:224-228) to cancel work still pending at removal.
Two behaviours fall out of this and surprise people:
-
throttleMsbeatsdebounceMs. The branch isif (options.throttleMs) … else if (options.debounceMs)— pass both anddebounceMsis silently discarded. -
once+debounceMsmeans the callback never fires. Debounce only schedules.oncethen removes the callback as soon as the hook fires, and removal runsteardown, clearing the pending timer before it elapses. Registration returns a valid id and nothing warns.
_throttle() (:466) and _debounce() (:480) are defined but never called — registerHook inlines both, so they are dead code, not the live path.
If key is supplied and the hook name already exists, a matching key returns the existing callbackId and registers nothing (:56-59).
Keys are scoped per hook name, not globally — the same key under two hook names is two registrations. Use it to make registration idempotent across re-entrant setup paths.
context is an arbitrary string grouping callbacks for bulk removal.
-
disposeByContext(context)(:321) iterates the id set, delegates toremoveCallback, then drops the context entry. -
_removeFromContexts(callbackId)(:491) breaks on first match — a callback is assumed to live in at most one context. -
HookContext(:543-548) provides the canonical string builders:scene(),token(),journalPage(),app(). Prefer these over hand-rolled strings so auto-dispose can match them.
Auto-dispose is wired in initialize() (:508-515) for exactly two lifecycles:
| Foundry hook | Disposes |
|---|---|
canvasTearDown |
scene:<previous scene id> |
deleteToken |
token:<token id> |
There is no module-unload auto-dispose, and there cannot be one. closeGame and unloadModule are both dead names — neither appears anywhere in Foundry v13, and nothing (Foundry, Blacksmith, or any installed module) ever fires them. Foundry has no runtime module-unload event: enabling or disabling a module reloads the world, which tears everything down regardless. Do not add a listener for either and assume cleanup runs.
Ids are generated as `${name}_${Date.now()}_${rand}` (_makeCallbackId, :17).
removeCallback finds the owning hook by searching the registry (:198-218). It must never parse the id. This is not stylistic: it previously did callbackId.split('_')[0] to recover the hook name, which silently returned the wrong name for any hook containing an underscore, and module-defined hooks commonly do (myModule_dataReady). The lookup missed, removeCallback returned false, and the callback leaked forever. Because disposeByContext delegates here, context cleanup silently failed too — the exact guarantee context exists to provide.
The id format is not a contract. Do not document it as one, and do not depend on it.
| Method | Behaviour |
|---|---|
removeHook(name) (:169) |
Drops every callback for the name, unhooks Foundry, deletes the entry. false if unknown, true on success. |
removeCallback(id) (:198) |
Removes one callback: runs teardown, unlinks context, splices. When the last callback goes, the Foundry hook is removed and the entry deleted (:237-239). false if not found. |
unregisterHook(...) (:271) |
Dual signature — ('name', callbackId) or ({ name, callbackId }). With a callbackId it delegates to removeCallback; without, to removeHook. |
disposeByContext(ctx) (:321) |
Bulk removal via removeCallback. |
cleanup() (:334) |
Unhooks everything. |
Registering renderChatMessage is silently rewritten to renderChatMessageHTML (:40-49), with a one-time console.warn guarded by _didWarnRenderChatMessageRemap (:12).
This matters beyond the name: v13 passes a native HTMLElement, not jQuery. Blacksmith bolts a .find() compatibility shim onto the element from a priority-1 hook in blacksmith.js, but that shim returns a plain Array carrying only .click — so .find() works and .addClass() throws. New code should register renderChatMessageHTML and use DOM methods.
initialize() (:526-536) installs window.blacksmithHooks(), blacksmithHookDetails(), blacksmithHookStats(), plus short aliases showHooks(), showHookDetails(), hookStats().
Introspection: getStats() (:358), getHookStats() (:373), hasHook() (:382), getHooksByPriority() (:440).
Two of these are stubs, so read their output with that in mind:
-
getHooksByCategory(category)(:454) ignores its argument and returns every hook — there is no category system. -
showHookDetails()prints a hardcodedCategories: [general](:419) for the same reason, and walks priorities 1..5 only, so out-of-range hooks do not appear in it.