Hooks
Overview
A hook is a named platform event with a typed argument list and a typed result. Modules register handlers with RegisterHook, and the code that owns the event dispatches it: ExecuteHooks runs every handler and discards what they return, CollectHooks gathers their return values, and GetRegisteredHooks hands back the handlers themselves for a caller that must invoke each one differently. HookSignatures ties each Hook member to its arguments and result, so the compiler checks a handler against the hook it registers for.
Everything on this page lives in @antelopejs-private/cms/interfaces/cms/hooks.
Register a Handler
import { Hook, RegisterHook } from "@antelopejs-private/cms/interfaces/cms/hooks";
export function registerBillingHooks(): void {
RegisterHook(Hook.MEMBER_ADDED, async (payload) => {
// payload: { tenantId, userId, isTenantOwner }
await seedBillingProfile(payload.tenantId, payload.userId);
return undefined;
});
RegisterHook(Hook.TENANT_DELETED, async (tenantId) => {
await dropBillingProfiles(tenantId);
return undefined;
});
}
RegisterHook<H>(name: H, callback: HookHandler<H>, options?: HookRegistrationOptions) appends the callback to the hook's list. The check is a TypeScript one: HookHandler<H> derives the parameter and return types from HookSignatures[H], so a mismatched handler is a compile error. At runtime the call performs no validation.
A hook whose result is undefined still expects a handler that returns it, so end such a handler with return undefined; rather than falling off the end.
Every registration records the module it came from, resolved from the registering call site. HookRegistrationOptions.moduleId overrides that resolution:
interface HookRegistrationOptions {
moduleId?: string;
}
The id only matters to a dispatcher that treats handlers per module rather than as one flat list — the tenant data export namespaces each contributor's archive entries by it. Set it explicitly whenever the registration happens somewhere the runtime would attribute to another module, such as a shared helper called during your module's construction.
UnregisterHook<H>(name: H, callback: HookHandler<H>) removes a previously registered callback by identity, so keep a reference to the function you registered:
import {
Hook,
type HookHandler,
RegisterHook,
UnregisterHook,
} from "@antelopejs-private/cms/interfaces/cms/hooks";
const onMemberAdded: HookHandler<Hook.MEMBER_ADDED> = async (payload) => {
await seedBillingProfile(payload.tenantId, payload.userId);
return undefined;
};
RegisterHook(Hook.MEMBER_ADDED, onMemberAdded);
UnregisterHook(Hook.MEMBER_ADDED, onMemberAdded);
HookCallback is the untyped shape the registry stores internally, (...args: any[]) => Promise<any> | any. Register through HookHandler<H> instead so the payload keeps its type.
The Hook Enum
| Member | Value | Arguments | Result |
|---|---|---|---|
Hook.DATABASE_INITIALIZED | "database:initialized" | none | undefined |
Hook.TENANT_BEING_PROVISIONED | "tenant:being-provisioned" | TenantProvisioningHookPayload | undefined |
Hook.TENANT_DELETED | "tenant:deleted" | tenantId: string | undefined |
Hook.TENANT_DATA_EXPORT | "tenant:data-export" | tenantId: string, archive: TenantExportArchive, signal: AbortSignal | TenantDataExportContribution | void |
Hook.INVITE_BEING_CREATED | "invite:being-created" | InviteHookPayload | undefined |
Hook.INVITE_CREATED | "invite:created" | InviteCreatedHookPayload | undefined |
Hook.INVITE_DELETED | "invite:deleted" | InviteDeletedHookPayload | undefined |
Hook.MEMBER_BEING_ADDED | "member:being-added" | MemberHookPayload | undefined |
Hook.MEMBER_ADDED | "member:added" | MemberHookPayload | undefined |
Hook.MEMBER_REMOVED | "member:removed" | MemberRemovedHookPayload | undefined |
Hook.USER_REGISTERED | "user:registered" | UserRegisteredHookPayload | undefined |
The *_BEING_* members fire before the write they precede; the past-tense members fire after it. TENANT_BEING_PROVISIONED has no past-tense counterpart — see Enrich a Workspace Being Provisioned.
Payload Interfaces
interface TenantProvisioningHookPayload {
tenantId: string;
userId: string;
extras: Record<string, unknown>;
}
interface InviteHookPayload {
tenantId: string;
email: string;
asTenantOwner: boolean;
roleIds: string[];
}
interface InviteCreatedHookPayload extends InviteHookPayload {
inviteId: string;
token: string;
}
interface InviteDeletedHookPayload {
tenantId: string;
inviteId: string;
email: string;
}
interface MemberHookPayload {
tenantId: string;
userId: string;
isTenantOwner: boolean;
}
interface MemberRemovedHookPayload {
tenantId: string;
userIds: string[];
}
interface UserRegisteredHookPayload {
tenantId: string;
userId: string;
email: string;
name: string;
}
The shapes are not uniform, and the difference between the addition and removal payloads is deliberate: MEMBER_BEING_ADDED and MEMBER_ADDED describe one member with userId, while MEMBER_REMOVED carries userIds, because a removal can cover several members at once. Let the handler's inferred parameter type guide you.
InviteCreatedHookPayload carries the invite token. It is a signup credential: keep it inside the handler rather than forwarding it to a wider surface.
TenantProvisioningHookPayload.extras is opaque: the CMS reads no key of it, validates nothing and stores nothing. See Enrich a Workspace Being Provisioned.
TENANT_DATA_EXPORT is the one hook whose handler produces something rather than only reacting. Its argument and return types live in @antelopejs-private/cms/interfaces/cms/tenant-export and are re-exported from this module, so either import path works. They are covered in Contribute to a Tenant Data Export.
Dispatch: ExecuteHooks vs CollectHooks
Both take the hook and its arguments and await each handler in registration order. They differ in what they do with the outcome.
async function ExecuteHooks<H extends Hook>(
name: H,
...args: HookSignatures[H]["args"]
): Promise<void>;
async function CollectHooks<H extends Hook>(
name: H,
...args: HookSignatures[H]["args"]
): Promise<HookSignatures[H]["result"][]>;
ExecuteHooks | CollectHooks | |
|---|---|---|
| Return value | Promise<void> — handler results are discarded | Promise<Result[]> — one entry per handler that returned |
| A handler that throws | The rejection propagates to the caller and the remaining handlers do not run | The error is logged and the handler contributes nothing; the remaining handlers still run |
| Use for | Notifying that something happened | Asking every handler to contribute something |
Use ExecuteHooks for the notification hooks — a failing handler should be able to fail the operation that fired it. Use CollectHooks for contribution hooks, where one broken module must not sink the whole collection.
import {
CollectHooks,
ExecuteHooks,
Hook,
} from "@antelopejs-private/cms/interfaces/cms/hooks";
// notify: every handler runs, nothing is returned
await ExecuteHooks(Hook.TENANT_DELETED, tenantId);
// collect: gather what every handler returned, skipping the ones that threw
const payloads = await CollectHooks(Hook.MEMBER_ADDED, {
tenantId,
userId,
isTenantOwner: false,
});
A caller that must not be broken by a subscriber wraps ExecuteHooks in its own try/catch — the CMS does this for USER_REGISTERED, where a failing subscriber would otherwise strand a user whose account was already created.
Dispatch Handler by Handler: GetRegisteredHooks
Both dispatchers pass the same arguments to every handler. When each handler needs arguments of its own, take the list instead:
function GetRegisteredHooks<H extends Hook>(name: H): RegisteredHook<H>[];
interface RegisteredHook<H extends Hook> {
moduleId?: string;
handler: HookHandler<H>;
}
moduleId is what the registration recorded — the HookRegistrationOptions.moduleId that was passed, or the module the runtime attributed the call to — and is undefined when neither resolved. Handling the failures is the caller's job here: nothing wraps the handler for you.
This is how the tenant data export runs. Each contributor needs an archive namespaced under its own module id, which no shared argument list can express.
Enrich a Workspace Being Provisioned
A SaaS signup provisions a workspace, and the product running it usually collects more at that moment than the CMS models — an acquisition source, a team size, a referral code. TENANT_BEING_PROVISIONED is where a module writes those to its own tables, while the provisioning is still undone.
import {
Hook,
RegisterHook,
} from "@antelopejs-private/cms/interfaces/cms/hooks";
RegisterHook(Hook.TENANT_BEING_PROVISIONED, async ({ tenantId, userId, extras }) => {
await recordAcquisition(tenantId, userId, {
source: extras.acquisitionSource,
referral: extras.referralCode,
});
return undefined;
});
The hook is dispatched with ExecuteHooks, so handlers run in series, each awaited, and a handler that throws aborts the provisioning: the rejection reaches the emitter, later handlers do not run, and the emitter unwinds the workspace it was creating. A workspace therefore never ends up provisioned while the enrichment a consumer needed failed to be written — that direction is the guarantee, and the reason to fire before the commit rather than after it.
The other direction is not guaranteed, and no hook could provide it: there is no shared transaction to enlist in. A handler that already returned has committed its own rows, and neither a later handler throwing nor the emitter failing afterwards takes them back. Two habits make that survivable:
- Key the write on
tenantId, so it can be cleaned up along with the workspace — subscribing toTENANT_DELETEDcovers the ordinary deletion path. - Make it idempotent, so a retried signup does not double it.
The flip side is the usual one for a *_BEING_* hook: a handler that throws for its own reasons fails somebody's signup, so keep it to what genuinely must not be lost.
extras is opaque by design. The CMS reads no key of it, validates nothing, and stores nothing — it carries the object from the caller of the provisioning flow to the handlers, and each handler writes what it recognises. The shape is a contract between that caller and that handler, one the CMS is deliberately not a party to, so a product can start collecting a new field without waiting on a CMS release.
No CMS code fires this hook: provisioning belongs to the signup flow of a SaaS module such as cms-saas. The CMS declares the contract; the emitter honours it. Without such a module, nothing invokes it.
Contribute to a Tenant Data Export
TENANT_DATA_EXPORT asks every module for its slice of one tenant's data, which is assembled into a single ZIP archive. A handler receives the tenant id, an archive to write into, and an AbortSignal. It has two ways to contribute, and may use both:
- Return a
TenantDataExportContribution— itsdatais serialized tomodules/<moduleId>.json. - Write entries into the
archive— for anything too large to hold in memory, or that is not JSON.
Register with RegisterTenantDataExportContributor rather than a bare RegisterHook. It names the module id explicitly, and that id is what namespaces the contributor's archive entries:
import { RegisterTenantDataExportContributor } from "@antelopejs-private/cms/interfaces/cms/hooks";
RegisterTenantDataExportContributor(
"@acme/cms-billing",
async (tenantId, archive, signal) => {
await archive.addStream(
"invoices.ndjson",
streamInvoices(tenantId, signal),
);
return {
moduleId: "@acme/cms-billing",
data: await collectBillingSettings(tenantId),
};
},
);
function RegisterTenantDataExportContributor(
moduleId: string,
handler: HookHandler<Hook.TENANT_DATA_EXPORT>,
): void;
A handler that only writes archive entries returns nothing.
interface TenantDataExportContribution {
moduleId: string;
data: unknown;
}
TenantExportArchive
import type { Readable } from "node:stream";
interface TenantExportArchive {
addJson(path: string, data: unknown): Promise<void>;
addFile(path: string, localPath: string): Promise<void>;
addStream(path: string, stream: Readable): Promise<void>;
}
| Method | Adds |
|---|---|
addJson | data serialized as a JSON entry. |
addFile | An artifact already materialized on disk, read from localPath. |
addStream | A remote or generated dump, without ever holding it in memory. |
Paths are relative: the implementation prefixes every entry with modules/<moduleId>/, so two contributors can both write invoices.ndjson without colliding, and none has to defend against another's paths.
Await every call. Each method resolves only once the archiver has consumed the entry — awaiting is what propagates back-pressure to whatever produces the data, and is what keeps memory flat however large the export grows. Firing them without await gives up both.
The signal aborts when the export is cancelled or times out. Pass it down to whatever produces your data so a cancelled export stops reading rather than finishing into a discarded archive.
The Manifest
Every run writes a manifest.json at the archive root recording what each contributor produced:
interface TenantExportManifest {
tenantId: string;
exportedAt: string;
partial: boolean;
modules: TenantExportModuleReport[];
}
interface TenantExportModuleReport {
moduleId: string;
declaredModuleId?: string;
entries: string[];
failed: boolean;
error?: string;
}
A failing contributor does not fail the export: its report carries failed: true and the error message, and partial goes true on the manifest so a consumer can tell an incomplete archive from a complete one. declaredModuleId appears when the returned contribution named a different id than the one the archive namespaced the entries under — a sign the registration and the return value disagree.
Running the export is the other side of this contract: buildTenantExportArchive drives the contributors and writes the ZIP, and startTenantExportJob runs it as a background export job. Both belong to the cms-base interface, under its helpers.
Next Steps
Realtime covers pushing an event to an open page instead of reacting to a platform event.