Files
hooks-lib/scripts/internal/subscribers.js
Kaysser Kayyali d038eb8c67 v0.3.0: rename module id hax-hooks-lib -> foundry-hooks-lib
User callout: 'Hax' is Kaysser's nickname. The module id should
not use it. Rename the Foundry module id from 'hax-hooks-lib' to
'foundry-hooks-lib'. Gitea repo name stays as 'hooks-lib' (kept
for the user-facing URL); the Gitea manifest URL is unchanged.

**Scope of rename:**
- module.json: id, title, version (0.2.0 -> 0.3.0), download URL
- package.json: name
- README.md, HOOK_CONTRACT.md, LICENSE: branding text
- All 6 production JS files: MODULE_ID constant + comments
- 4 active test files: console.log strings + test descriptions
- Rename of release zips in git: hooks-lib-X.Y.Z.zip ->
  foundry-hooks-lib-X.Y.Z.zip (preserves the v0.1.0 and v0.2.0
  zips as historical artifacts; the v0.3.0 zip is the new
  release artifact)
- .gitignore: glob + un-ignore lines updated to match

**Out of scope (deliberate):**
- Gitea repo name 'kaykayyali/hooks-lib' stays. Per the user's
  direction, only the module id is renamed; the Gitea URL path
  is preserved for the existing 'url', 'manifest', 'download'
  fields.
- scripts/_archive/v0.1.0/*: historical v0.1.0 code is left
  as-is. Those files tested 'hax-hooks-lib v0.1.0'; rewriting
  the history would be misleading.
- tests/_archive_v0.1.0_*.mjs: same reason, left untouched.
- .hermes/plans/* session-historian plans that reference
  'Hax's Tools split': session artifact, not a release asset.

**Verification:** 554/554 smoke assertions pass, 6/6 perf
assertions pass, median 0.0004ms/fire (well under 0.1ms
budget). No logic change; rename is string-only.

**Consumer action required:** battle-focus and its-achievable
both declare 'relationships.requires' pointing to
'hax-hooks-lib'. The next commits on those repos will update
their relationships to 'foundry-hooks-lib' + bump their
versions. Foundry instances with v0.2.0 of the old id
installed will need to be reinstalled as v0.3.0 of the new
id.
2026-06-20 16:53:37 -04:00

139 lines
4.1 KiB
JavaScript

// scripts/internal/subscribers.js
//
// HOOK_CONTRACT.md §3: subscribe primitives + §3.5 error containment.
//
// subscribe(hookName, fn) — single hook, single handler
// subscribeMany(map) — atomic batch
// subscribeAll(fn) — every hook, one handler
// unsubscribeAll() — purge everything
//
// Error containment (§3.5): throwing consumer callbacks are caught,
// logged via console.error with the [foundry-hooks-lib] prefix and the
// hook name, and the dispatch chain continues to the next callback.
// Errors never propagate to Foundry's hook chain.
//
// Multiple subscriptions for the same hook stack in registration
// order.
import { REGISTERED_HOOKS } from "./registered-hooks.js";
const MODULE_ID = "foundry-hooks-lib";
// Per-envelope callback list. Order = registration order.
const _callbacks = new Map(); // hookName (envelope) -> [fn, fn, ...]
const _allCallbacks = []; // subscribeAll subscribers, in order
function assertRegistered(hookName) {
if (!REGISTERED_HOOKS.includes(hookName)) {
throw new TypeError(
`subscribe("${hookName}"): hook not in REGISTERED_HOOKS. ` +
`Available hooks: ${REGISTERED_HOOKS.length}. Use listActiveAdapters() + adapter factory to add system-specific derived events.`
);
}
}
export function subscribe(hookName, fn) {
if (typeof fn !== "function") {
throw new TypeError(`subscribe(hookName, fn): fn must be a function`);
}
assertRegistered(hookName);
if (!_callbacks.has(hookName)) {
_callbacks.set(hookName, []);
}
_callbacks.get(hookName).push(fn);
return () => {
const list = _callbacks.get(hookName);
if (!list) return;
const idx = list.indexOf(fn);
if (idx >= 0) list.splice(idx, 1);
};
}
export function subscribeMany(map) {
if (!map || typeof map !== "object") {
throw new TypeError(`subscribeMany(map): map must be an object`);
}
const entries = Object.entries(map);
// Atomicity: validate all names first. If any fails, throw WITHOUT
// registering any. (We don't register until all checks pass.)
for (const [name, fn] of entries) {
if (typeof fn !== "function") {
throw new TypeError(
`subscribeMany: handler for "${name}" must be a function`
);
}
assertRegistered(name);
}
// All validated — register all.
const unsubs = entries.map(([name, fn]) => subscribe(name, fn));
return () => {
for (const u of unsubs) u();
};
}
export function subscribeAll(fn) {
if (typeof fn !== "function") {
throw new TypeError(`subscribeAll(fn): fn must be a function`);
}
_allCallbacks.push(fn);
return () => {
const idx = _allCallbacks.indexOf(fn);
if (idx >= 0) _allCallbacks.splice(idx, 1);
};
}
export function unsubscribeAll() {
_callbacks.clear();
_allCallbacks.length = 0;
}
// Internal: dispatch an envelope to all matching callbacks + all
// subscribeAll subscribers. Called by the envelope dispatcher.
//
// For sync-mode hooks, this runs inline. For async-mode hooks, the
// envelope dispatcher wraps this in a microtask.
//
// Returns true if any callback threw (for diagnostics). Never re-throws.
export function dispatch(envelope) {
let threwAny = false;
// Targeted callbacks.
const list = _callbacks.get(envelope.hook);
if (list) {
for (const fn of list) {
try {
fn(envelope);
} catch (e) {
threwAny = true;
console.error(
`[${MODULE_ID}] consumer callback for "${envelope.hook}" threw:`,
e
);
}
}
}
// Wildcard subscribers (subscribeAll). These also receive every
// envelope; consumers dispatch on envelope.hook themselves.
for (const fn of _allCallbacks) {
try {
fn(envelope);
} catch (e) {
threwAny = true;
console.error(
`[${MODULE_ID}] subscribeAll callback for "${envelope.hook}" threw:`,
e
);
}
}
return threwAny;
}
// Internal: list envelope names that have at least one callback.
// Used by tests for verification.
export function listSubscribedHooks() {
return [..._callbacks.keys()];
}
export function hasCallbacksFor(envelopeName) {
const list = _callbacks.get(envelopeName);
return !!list && list.length > 0;
}