From 3d0e086c5501487ab62e31bbf240c20f718d95a9 Mon Sep 17 00:00:00 2001 From: Nicola Krumschmidt Date: Wed, 3 Nov 2021 22:18:56 +0100 Subject: [PATCH] Rework hook registration (#8027) * Rework hook registration * Remove event and action fields from hook payloads * Move "error" action to "request.error" filter * Emit meta and context objects in filters and actions * Run filters sequentially * Update hook templates * Fix CLI hook test * Also emit `.items.crud` when emitting `items.crud`. * Update hook docs Co-authored-by: Oreilles Co-authored-by: rijkvanzanten --- api/src/app.ts | 19 +- api/src/cli/index.test.ts | 16 +- api/src/cli/index.ts | 6 +- api/src/controllers/not-found.ts | 14 +- api/src/emitter.ts | 104 ++++-- api/src/exceptions/database/translate.ts | 17 +- api/src/extensions.ts | 82 +++-- api/src/middleware/error-handler.ts | 20 +- api/src/server.ts | 34 +- api/src/services/authentication.ts | 94 +++--- api/src/services/fields.ts | 46 +-- api/src/services/files.ts | 25 +- api/src/services/items.ts | 188 +++++------ api/src/types/extensions.ts | 23 +- api/src/webhooks.ts | 13 +- docs/extensions/hooks.md | 311 +++++++++--------- .../templates/hook/javascript/src/index.js | 12 +- .../templates/hook/typescript/src/index.ts | 12 +- packages/shared/src/types/hooks.ts | 22 +- 19 files changed, 629 insertions(+), 429 deletions(-) diff --git a/api/src/app.ts b/api/src/app.ts index 267b896130..dd57846045 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -28,7 +28,7 @@ import usersRouter from './controllers/users'; import utilsRouter from './controllers/utils'; import webhooksRouter from './controllers/webhooks'; import { isInstalled, validateDatabaseConnection, validateDatabaseExtensions, validateMigrations } from './database'; -import { emitAsyncSafe } from './emitter'; +import emitter from './emitter'; import env from './env'; import { InvalidPayloadException } from './exceptions'; import { getExtensionManager } from './extensions'; @@ -87,9 +87,9 @@ export default async function createApp(): Promise { app.set('trust proxy', true); app.set('query parser', (str: string) => qs.parse(str, { depth: 10 })); - await emitAsyncSafe('init.before', { app }); + await emitter.emitInit('app.before', { app }); - await emitAsyncSafe('middlewares.init.before', { app }); + await emitter.emitInit('middlewares.before', { app }); app.use(expressLogger); @@ -160,9 +160,9 @@ export default async function createApp(): Promise { app.use(getPermissions); - await emitAsyncSafe('middlewares.init.after', { app }); + await emitter.emitInit('middlewares.after', { app }); - await emitAsyncSafe('routes.init.before', { app }); + await emitter.emitInit('routes.before', { app }); app.use('/auth', authRouter); @@ -189,21 +189,22 @@ export default async function createApp(): Promise { app.use('/utils', utilsRouter); app.use('/webhooks', webhooksRouter); - await emitAsyncSafe('routes.custom.init.before', { app }); + // Register custom endpoints + await emitter.emitInit('routes.custom.before', { app }); app.use(extensionManager.getEndpointRouter()); - await emitAsyncSafe('routes.custom.init.after', { app }); + await emitter.emitInit('routes.custom.after', { app }); app.use(notFoundHandler); app.use(errorHandler); - await emitAsyncSafe('routes.init.after', { app }); + await emitter.emitInit('routes.after', { app }); // Register all webhooks await registerWebhooks(); track('serverStarted'); - emitAsyncSafe('init'); + await emitter.emitInit('app.after', { app }); return app; } diff --git a/api/src/cli/index.test.ts b/api/src/cli/index.test.ts index 4d9108c7be..e48039117d 100644 --- a/api/src/cli/index.test.ts +++ b/api/src/cli/index.test.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; -import { Extension } from '@directus/shared/types'; -import { createCli } from '.'; +import { Extension, HookConfig } from '@directus/shared/types'; +import { createCli } from './index'; jest.mock('../env', () => ({ ...jest.requireActual('../env').default, @@ -19,7 +19,7 @@ jest.mock('@directus/shared/utils/node/get-extensions', () => ({ getLocalExtensions: jest.fn(() => Promise.resolve([customCliExtension])), })); -jest.mock(`/hooks/custom-cli/index.js`, () => () => customCliHook, { virtual: true }); +jest.mock(`/hooks/custom-cli/index.js`, () => customCliHook, { virtual: true }); const customCliExtension: Extension = { path: `/hooks/custom-cli`, @@ -31,8 +31,14 @@ const customCliExtension: Extension = { const beforeHook = jest.fn(); const afterAction = jest.fn(); -const afterHook = jest.fn(({ program }: { program: Command }) => program.command('custom').action(afterAction)); -const customCliHook = { 'cli.init.before': beforeHook, 'cli.init.after': afterHook }; +const afterHook = jest.fn(({ program }) => { + (program as Command).command('custom').action(afterAction); +}); + +const customCliHook: HookConfig = ({ init }) => { + init('cli.before', beforeHook); + init('cli.after', afterHook); +}; const writeOut = jest.fn(); const writeErr = jest.fn(); diff --git a/api/src/cli/index.ts b/api/src/cli/index.ts index c91ab922ce..bb85fbd916 100644 --- a/api/src/cli/index.ts +++ b/api/src/cli/index.ts @@ -1,6 +1,6 @@ import { Command, Option } from 'commander'; import { startServer } from '../server'; -import { emitAsyncSafe } from '../emitter'; +import emitter from '../emitter'; import { getExtensionManager } from '../extensions'; import bootstrap from './commands/bootstrap'; import count from './commands/count'; @@ -22,7 +22,7 @@ export async function createCli(): Promise { await extensionManager.initialize({ schedule: false }); - await emitAsyncSafe('cli.init.before', { program }); + await emitter.emitInit('cli.before', { program }); program.name('directus').usage('[command] [options]'); program.version(pkg.version, '-v, --version'); @@ -95,7 +95,7 @@ export async function createCli(): Promise { .argument('', 'Path to snapshot file') .action(apply); - await emitAsyncSafe('cli.init.after', { program }); + await emitter.emitInit('cli.after', { program }); return program; } diff --git a/api/src/controllers/not-found.ts b/api/src/controllers/not-found.ts index 9d1d30fd74..f83faeee9e 100644 --- a/api/src/controllers/not-found.ts +++ b/api/src/controllers/not-found.ts @@ -1,4 +1,5 @@ import { RequestHandler } from 'express'; +import getDatabase from '../database'; import emitter from '../emitter'; import { RouteNotFoundException } from '../exceptions'; @@ -15,8 +16,17 @@ import { RouteNotFoundException } from '../exceptions'; */ const notFound: RequestHandler = async (req, res, next) => { try { - const hooksResult = await emitter.emitAsync('request.not_found', req, res); - if (hooksResult.reduce((prev, current) => current || prev, false)) { + const hooksResult = await emitter.emitFilter( + 'request.not_found', + false, + { request: req, response: res }, + { + database: getDatabase(), + schema: req.schema, + accountability: req.accountability ?? null, + } + ); + if (hooksResult) { return next(); } next(new RouteNotFoundException(req.path)); diff --git a/api/src/emitter.ts b/api/src/emitter.ts index 79397dee46..82ebb362e2 100644 --- a/api/src/emitter.ts +++ b/api/src/emitter.ts @@ -1,28 +1,94 @@ import { EventEmitter2 } from 'eventemitter2'; import logger from './logger'; +import { ActionHandler, FilterHandler, HookContext, InitHandler } from './types'; -const emitter = new EventEmitter2({ - wildcard: true, - verboseMemoryLeak: true, - delimiter: '.', +class Emitter { + private filterEmitter; + private actionEmitter; + private initEmitter; - // This will ignore the "unspecified event" error - ignoreErrors: true, -}); + constructor() { + const emitterOptions = { + wildcard: true, + verboseMemoryLeak: true, + delimiter: '.', -/** - * Emit async events without throwing errors. Just log them out as warnings. - * @param name - * @param args - */ -export async function emitAsyncSafe(name: string, ...args: any[]): Promise { - try { - return await emitter.emitAsync(name, ...args); - } catch (err: any) { - logger.warn(`An error was thrown while executing hook "${name}"`); - logger.warn(err); + // This will ignore the "unspecified event" error + ignoreErrors: true, + }; + + this.filterEmitter = new EventEmitter2(emitterOptions); + this.actionEmitter = new EventEmitter2(emitterOptions); + this.initEmitter = new EventEmitter2(emitterOptions); + } + + public eventsToEmit(event: string, meta: Record) { + if (event.startsWith('items')) { + return [event, `${meta.collection}.${event}`]; + } + return [event]; + } + + public async emitFilter(event: string, payload: T, meta: Record, context: HookContext): Promise { + const events = this.eventsToEmit(event, meta); + const listeners = events.flatMap((event) => this.filterEmitter.listeners(event)) as FilterHandler[]; + + let updatedPayload = payload; + for (const listener of listeners) { + const result = await listener(updatedPayload, meta, context); + + if (result !== undefined) { + updatedPayload = result; + } + } + + return updatedPayload; + } + + public emitAction(event: string, meta: Record, context: HookContext): void { + const events = this.eventsToEmit(event, meta); + for (const event of events) { + this.actionEmitter.emitAsync(event, meta, context).catch((err) => { + logger.warn(`An error was thrown while executing action "${event}"`); + logger.warn(err); + }); + } + } + + public async emitInit(event: string, meta: Record): Promise { + try { + await this.initEmitter.emitAsync(event, meta); + } catch (err: any) { + logger.warn(`An error was thrown while executing init "${event}"`); + logger.warn(err); + } + } + + public onFilter(event: string, handler: FilterHandler): void { + this.filterEmitter.on(event, handler); + } + + public onAction(event: string, handler: ActionHandler): void { + this.actionEmitter.on(event, handler); + } + + public onInit(event: string, handler: InitHandler): void { + this.initEmitter.on(event, handler); + } + + public offFilter(event: string, handler: FilterHandler): void { + this.filterEmitter.off(event, handler); + } + + public offAction(event: string, handler: ActionHandler): void { + this.actionEmitter.off(event, handler); + } + + public offInit(event: string, handler: InitHandler): void { + this.initEmitter.off(event, handler); } - return []; } +const emitter = new Emitter(); + export default emitter; diff --git a/api/src/exceptions/database/translate.ts b/api/src/exceptions/database/translate.ts index 06ae4fcfb9..b3cb067a72 100644 --- a/api/src/exceptions/database/translate.ts +++ b/api/src/exceptions/database/translate.ts @@ -1,5 +1,4 @@ -import { compact, last } from 'lodash'; -import { getDatabaseClient } from '../../database'; +import getDatabase, { getDatabaseClient } from '../../database'; import emitter from '../../emitter'; import { extractError as mssql } from './dialects/mssql'; import { extractError as mysql } from './dialects/mysql'; @@ -39,8 +38,16 @@ export async function translateDatabaseError(error: SQLError): Promise { break; } - const hookResult = await emitter.emitAsync('database.error', defaultError, { client }); - const hookError = Array.isArray(hookResult) ? last(compact(hookResult)) : hookResult; + const hookError = await emitter.emitFilter( + 'database.error', + defaultError, + { client }, + { + database: getDatabase(), + schema: null, + accountability: null, + } + ); - return hookError || defaultError; + return hookError; } diff --git a/api/src/extensions.ts b/api/src/extensions.ts index c433edeadd..e8f3e407e2 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -21,13 +21,12 @@ import emitter from './emitter'; import env from './env'; import * as exceptions from './exceptions'; import logger from './logger'; -import { HookConfig, EndpointConfig } from './types'; +import { HookConfig, EndpointConfig, FilterHandler, ActionHandler, InitHandler, ScheduleHandler } from './types'; import fse from 'fs-extra'; import { getSchema } from './utils/get-schema'; import * as services from './services'; import { schedule, ScheduledTask, validate } from 'node-cron'; -import { REGEX_BETWEEN_PARENS } from '@directus/shared/constants'; import { rollup } from 'rollup'; // @TODO Remove this once a new version of @rollup/plugin-virtual has been released // @ts-expect-error @@ -35,7 +34,6 @@ import virtual from '@rollup/plugin-virtual'; import alias from '@rollup/plugin-alias'; import { Url } from './utils/url'; import getModuleDefault from './utils/get-module-default'; -import { ListenerFn } from 'eventemitter2'; let extensionManager: ExtensionManager | undefined; @@ -57,8 +55,10 @@ class ExtensionManager { private appExtensions: Partial> = {}; private apiHooks: ( - | { type: 'cron'; path: string; task: ScheduledTask } - | { type: 'event'; path: string; event: string; handler: ListenerFn } + | { type: 'filter'; path: string; event: string; handler: FilterHandler } + | { type: 'action'; path: string; event: string; handler: ActionHandler } + | { type: 'init'; path: string; event: string; handler: InitHandler } + | { type: 'schedule'; path: string; task: ScheduledTask } )[] = []; private apiEndpoints: { path: string }[] = []; @@ -223,15 +223,39 @@ class ExtensionManager { const register = getModuleDefault(hookInstance); - const events = register({ services, exceptions, env, database: getDatabase(), logger, getSchema }); + const registerFunctions = { + filter: (event: string, handler: FilterHandler) => { + emitter.onFilter(event, handler); - for (const [event, handler] of Object.entries(events)) { - if (event.startsWith('cron(')) { - const cron = event.match(REGEX_BETWEEN_PARENS)?.[1]; + this.apiHooks.push({ + type: 'filter', + path: hookPath, + event, + handler, + }); + }, + action: (event: string, handler: ActionHandler) => { + emitter.onAction(event, handler); - if (!cron || validate(cron) === false) { - logger.warn(`Couldn't register cron hook. Provided cron is invalid: ${cron}`); - } else { + this.apiHooks.push({ + type: 'action', + path: hookPath, + event, + handler, + }); + }, + init: (event: string, handler: InitHandler) => { + emitter.onInit(event, handler); + + this.apiHooks.push({ + type: 'init', + path: hookPath, + event, + handler, + }); + }, + schedule: (cron: string, handler: ScheduleHandler) => { + if (validate(cron)) { const task = schedule(cron, async () => { if (this.isScheduleHookEnabled) { try { @@ -243,22 +267,17 @@ class ExtensionManager { }); this.apiHooks.push({ - type: 'cron', + type: 'schedule', path: hookPath, task, }); + } else { + logger.warn(`Couldn't register cron hook. Provided cron is invalid: ${cron}`); } - } else { - emitter.on(event, handler); + }, + }; - this.apiHooks.push({ - type: 'event', - path: hookPath, - event, - handler, - }); - } - } + register(registerFunctions, { services, exceptions, env, database: getDatabase(), logger, getSchema }); } private registerEndpoint(endpoint: Extension, router: Router) { @@ -282,10 +301,19 @@ class ExtensionManager { private unregisterHooks(): void { for (const hook of this.apiHooks) { - if (hook.type === 'cron') { - hook.task.destroy(); - } else { - emitter.off(hook.event, hook.handler); + switch (hook.type) { + case 'filter': + emitter.offFilter(hook.event, hook.handler); + break; + case 'action': + emitter.offAction(hook.event, hook.handler); + break; + case 'init': + emitter.offInit(hook.event, hook.handler); + break; + case 'schedule': + hook.task.destroy(); + break; } delete require.cache[require.resolve(hook.path)]; diff --git a/api/src/middleware/error-handler.ts b/api/src/middleware/error-handler.ts index 88a6ecff17..647d7201fc 100644 --- a/api/src/middleware/error-handler.ts +++ b/api/src/middleware/error-handler.ts @@ -1,10 +1,11 @@ import { ErrorRequestHandler } from 'express'; -import { emitAsyncSafe } from '../emitter'; +import emitter from '../emitter'; import env from '../env'; import { MethodNotAllowedException } from '../exceptions'; import { BaseException } from '@directus/shared/exceptions'; import logger from '../logger'; import { toArray } from '@directus/shared/utils'; +import getDatabase from '../database'; // Note: keep all 4 parameters here. That's how Express recognizes it's the error handler, even if // we don't use next @@ -87,9 +88,20 @@ const errorHandler: ErrorRequestHandler = (err, req, res, _next) => { } } - emitAsyncSafe('error', payload.errors).then(() => { - return res.json(payload); - }); + emitter + .emitFilter( + 'request.error', + payload.errors, + {}, + { + database: getDatabase(), + schema: req.schema, + accountability: req.accountability ?? null, + } + ) + .then(() => { + return res.json(payload); + }); }; export default errorHandler; diff --git a/api/src/server.ts b/api/src/server.ts index 517f9fa105..ef5036924f 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -9,7 +9,7 @@ import createApp from './app'; import getDatabase from './database'; import env from './env'; import logger from './logger'; -import emitter, { emitAsyncSafe } from './emitter'; +import emitter from './emitter'; import checkForUpdate from 'update-check'; import pkg from '../package.json'; @@ -67,7 +67,11 @@ export async function createServer(): Promise { duration: elapsedMilliseconds.toFixed(), }; - emitAsyncSafe('response', info); + emitter.emitAction('response', info, { + database: getDatabase(), + schema: req.schema, + accountability: req.accountability ?? null, + }); }); res.once('finish', complete.bind(null, true)); @@ -87,8 +91,6 @@ export async function createServer(): Promise { return server; async function beforeShutdown() { - emitAsyncSafe('server.stop.before', { server }); - if (env.NODE_ENV !== 'development') { logger.info('Shutting down...'); } @@ -97,11 +99,20 @@ export async function createServer(): Promise { async function onSignal() { const database = getDatabase(); await database.destroy(); + logger.info('Database connections destroyed'); } async function onShutdown() { - emitAsyncSafe('server.stop'); + emitter.emitAction( + 'server.stop', + {}, + { + database: getDatabase(), + schema: null, + accountability: null, + } + ); if (env.NODE_ENV !== 'development') { logger.info('Directus shut down OK. Bye bye!'); @@ -112,8 +123,6 @@ export async function createServer(): Promise { export async function startServer(): Promise { const server = await createServer(); - await emitter.emitAsync('server.start.before', { server }); - const port = env.PORT; server @@ -129,7 +138,16 @@ export async function startServer(): Promise { }); logger.info(`Server started at http://localhost:${port}`); - emitAsyncSafe('server.start'); + + emitter.emitAction( + 'server.start', + {}, + { + database: getDatabase(), + schema: null, + accountability: null, + } + ); }) .once('error', (err: any) => { if (err?.code === 'EADDRINUSE') { diff --git a/api/src/services/authentication.ts b/api/src/services/authentication.ts index b98f4b17ff..5ad46c4f38 100644 --- a/api/src/services/authentication.ts +++ b/api/src/services/authentication.ts @@ -3,7 +3,7 @@ import { Knex } from 'knex'; import ms from 'ms'; import { nanoid } from 'nanoid'; import getDatabase from '../database'; -import emitter, { emitAsyncSafe } from '../emitter'; +import emitter from '../emitter'; import env from '../env'; import { getAuthProvider } from '../auth'; import { DEFAULT_AUTH_PROVIDER } from '../constants'; @@ -14,7 +14,7 @@ import { TFAService } from './tfa'; import { AbstractServiceOptions, Action, SchemaOverview, Session, User, SessionData } from '../types'; import { Accountability } from '@directus/shared/types'; import { SettingsService } from './settings'; -import { merge, clone, cloneDeep, omit } from 'lodash'; +import { clone, cloneDeep, omit } from 'lodash'; import { performance } from 'perf_hooks'; import { stall } from '../utils/stall'; import logger from '../logger'; @@ -69,34 +69,36 @@ export class AuthenticationService { .andWhere('provider', providerName) .first(); - const updatedPayload = await emitter.emitAsync('auth.login.before', { - event: 'auth.login.before', - action: 'login', - schema: this.schema, - payload: payload, - provider: providerName, - accountability: this.accountability, - status: 'pending', - user: user?.id, - database: this.knex, - }); - - if (updatedPayload) { - payload = updatedPayload.length > 0 ? updatedPayload.reduce((acc, val) => merge(acc, val), {}) : payload; - } + const updatedPayload = await emitter.emitFilter( + 'auth.login', + payload, + { + status: 'pending', + user: user?.id, + provider: providerName, + }, + { + database: this.knex, + schema: this.schema, + accountability: this.accountability, + } + ); const emitStatus = (status: 'fail' | 'success') => { - emitAsyncSafe('auth.login', { - event: 'auth.login', - action: 'login', - schema: this.schema, - payload: payload, - provider: providerName, - accountability: this.accountability, - status, - user: user?.id, - database: this.knex, - }); + emitter.emitAction( + 'auth.login', + { + payload: updatedPayload, + status, + user: user?.id, + provider: providerName, + }, + { + database: this.knex, + schema: this.schema, + accountability: this.accountability, + } + ); }; if (user?.status !== 'active') { @@ -137,7 +139,7 @@ export class AuthenticationService { let sessionData: SessionData = null; try { - sessionData = await provider.login(clone(user), cloneDeep(payload)); + sessionData = await provider.login(clone(user), cloneDeep(updatedPayload)); } catch (e) { emitStatus('fail'); await stall(STALL_TIME, timeStart); @@ -161,28 +163,26 @@ export class AuthenticationService { } } - let tokenPayload = { + const tokenPayload = { id: user.id, }; - const customClaims = await emitter.emitAsync('auth.jwt.before', tokenPayload, { - event: 'auth.jwt.before', - action: 'jwt', - schema: this.schema, - payload: tokenPayload, - provider: providerName, - accountability: this.accountability, - status: 'pending', - user: user?.id, - database: this.knex, - }); + const customClaims = await emitter.emitFilter( + 'auth.jwt', + tokenPayload, + { + status: 'pending', + user: user?.id, + provider: providerName, + }, + { + database: this.knex, + schema: this.schema, + accountability: this.accountability, + } + ); - if (customClaims) { - tokenPayload = - customClaims.length > 0 ? customClaims.reduce((acc, val) => merge(acc, val), tokenPayload) : tokenPayload; - } - - const accessToken = jwt.sign(tokenPayload, env.SECRET as string, { + const accessToken = jwt.sign(customClaims, env.SECRET as string, { expiresIn: env.ACCESS_TOKEN_TTL, issuer: 'directus', }); diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index 5eb356d7b7..a3538ca122 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -5,7 +5,7 @@ import { getCache } from '../cache'; import { ALIAS_TYPES } from '../constants'; import getDatabase, { getSchemaInspector } from '../database'; import { systemFieldRows } from '../database/system-data/fields/'; -import emitter, { emitAsyncSafe } from '../emitter'; +import emitter from '../emitter'; import env from '../env'; import { ForbiddenException, InvalidPayloadException } from '../exceptions'; import { translateDatabaseError } from '../exceptions/database/translate'; @@ -326,16 +326,18 @@ export class FieldsService { throw new ForbiddenException(); } - await emitter.emitAsync(`fields.delete.before`, { - event: `fields.delete.before`, - accountability: this.accountability, - collection: collection, - item: field, - action: 'delete', - payload: null, - schema: this.schema, - database: this.knex, - }); + await emitter.emitFilter( + 'fields.delete', + [field], + { + collection: collection, + }, + { + database: this.knex, + schema: this.schema, + accountability: this.accountability, + } + ); await this.knex.transaction(async (trx) => { const relations = this.schema.relations.filter((relation) => { @@ -430,16 +432,18 @@ export class FieldsService { await this.systemCache.clear(); - emitAsyncSafe(`fields.delete`, { - event: `fields.delete`, - accountability: this.accountability, - collection: collection, - item: field, - action: 'delete', - payload: null, - schema: this.schema, - database: this.knex, - }); + emitter.emitAction( + 'fields.delete', + { + payload: [field], + collection: collection, + }, + { + database: this.knex, + schema: this.schema, + accountability: this.accountability, + } + ); } public addColumnToTable(table: Knex.CreateTableBuilder, field: RawField | Field, alter: Column | null = null): void { diff --git a/api/src/services/files.ts b/api/src/services/files.ts index de9d6fbfea..66d9cf4f24 100644 --- a/api/src/services/files.ts +++ b/api/src/services/files.ts @@ -6,7 +6,7 @@ import { extension } from 'mime-types'; import path from 'path'; import sharp from 'sharp'; import url from 'url'; -import { emitAsyncSafe } from '../emitter'; +import emitter from '../emitter'; import env from '../env'; import { ForbiddenException, ServiceUnavailableException } from '../exceptions'; import logger from '../logger'; @@ -124,16 +124,19 @@ export class FilesService extends ItemsService { await this.cache.clear(); } - emitAsyncSafe(`files.upload`, { - event: `files.upload`, - accountability: this.accountability, - collection: this.collection, - item: primaryKey, - action: 'upload', - payload, - schema: this.schema, - database: this.knex, - }); + emitter.emitAction( + 'files.upload', + { + payload, + key: primaryKey, + collection: this.collection, + }, + { + database: this.knex, + schema: this.schema, + accountability: this.accountability, + } + ); return primaryKey; } diff --git a/api/src/services/items.ts b/api/src/services/items.ts index 6a41a84299..02e51130cf 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -1,10 +1,10 @@ import { Knex } from 'knex'; -import { clone, cloneDeep, merge, pick, without } from 'lodash'; +import { clone, cloneDeep, pick, without } from 'lodash'; import { getCache } from '../cache'; import Keyv from 'keyv'; import getDatabase from '../database'; import runAST from '../database/run-ast'; -import emitter, { emitAsyncSafe } from '../emitter'; +import emitter from '../emitter'; import env from '../env'; import { ForbiddenException } from '../exceptions'; import { translateDatabaseError } from '../exceptions/database/translate'; @@ -105,26 +105,21 @@ export class ItemsService implements AbstractSer // Run all hooks that are attached to this event so the end user has the chance to augment the // item that is about to be saved - const hooksResult = + const payloadAfterHooks = opts?.emitEvents !== false - ? ( - await emitter.emitAsync(`${this.eventScope}.create.before`, payload, { - event: `${this.eventScope}.create.before`, - accountability: this.accountability, + ? await emitter.emitFilter( + `${this.eventScope}.create`, + payload, + { collection: this.collection, - item: null, - action: 'create', - payload, - schema: this.schema, + }, + { database: trx, - }) - ).filter((val) => val) - : []; - - // The events are fired last-to-first based on when they were created. By reversing the - // output array of results, we ensure that the augmentations are applied in - // "chronological" order - const payloadAfterHooks = hooksResult.length > 0 ? hooksResult.reduce((val, acc) => merge(acc, val)) : payload; + schema: this.schema, + accountability: this.accountability, + } + ) + : payload; const payloadWithPresets = this.accountability ? await authorizationService.validatePayload('create', this.collection, payloadAfterHooks) @@ -208,18 +203,21 @@ export class ItemsService implements AbstractSer }); if (opts?.emitEvents !== false) { - emitAsyncSafe(`${this.eventScope}.create`, { - event: `${this.eventScope}.create`, - accountability: this.accountability, - collection: this.collection, - item: primaryKey, - action: 'create', - payload, - schema: this.schema, - // This hook is called async. If we would pass the transaction here, the hook can be - // called after the transaction is done #5460 - database: getDatabase(), - }); + emitter.emitAction( + `${this.eventScope}.create`, + { + payload, + key: primaryKey, + collection: this.collection, + }, + { + // This hook is called async. If we would pass the transaction here, the hook can be + // called after the transaction is done #5460 + database: getDatabase(), + schema: this.schema, + accountability: this.accountability, + } + ); } if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { @@ -290,16 +288,19 @@ export class ItemsService implements AbstractSer throw new ForbiddenException(); } - emitAsyncSafe(`${this.eventScope}.read`, { - event: `${this.eventScope}.read`, - accountability: this.accountability, - collection: this.collection, - query, - action: 'read', - payload: records, - schema: this.schema, - database: getDatabase(), - }); + emitter.emitAction( + `${this.eventScope}.read`, + { + payload: records, + query, + collection: this.collection, + }, + { + database: getDatabase(), + schema: this.schema, + accountability: this.accountability, + } + ); return records as Item[]; } @@ -394,26 +395,22 @@ export class ItemsService implements AbstractSer // Run all hooks that are attached to this event so the end user has the chance to augment the // item that is about to be saved - const hooksResult = + const payloadAfterHooks = opts?.emitEvents !== false - ? ( - await emitter.emitAsync(`${this.eventScope}.update.before`, payload, { - event: `${this.eventScope}.update.before`, - accountability: this.accountability, + ? await emitter.emitFilter( + `${this.eventScope}.update`, + payload, + { + keys, collection: this.collection, - item: keys, - action: 'update', - payload, - schema: this.schema, + }, + { database: this.knex, - }) - ).filter((val) => val) - : []; - - // The events are fired last-to-first based on when they were created. By reversing the - // output array of results, we ensure that the augmentations are applied in - // "chronological" order - const payloadAfterHooks = hooksResult.length > 0 ? hooksResult.reduce((val, acc) => merge(acc, val)) : payload; + schema: this.schema, + accountability: this.accountability, + } + ) + : payload; if (this.accountability) { await authorizationService.checkAccess('update', this.collection, keys); @@ -521,18 +518,21 @@ export class ItemsService implements AbstractSer } if (opts?.emitEvents !== false) { - emitAsyncSafe(`${this.eventScope}.update`, { - event: `${this.eventScope}.update`, - accountability: this.accountability, - collection: this.collection, - item: keys, - action: 'update', - payload, - schema: this.schema, - // This hook is called async. If we would pass the transaction here, the hook can be - // called after the transaction is done #5460 - database: getDatabase(), - }); + emitter.emitAction( + `${this.eventScope}.update`, + { + payload, + keys, + collection: this.collection, + }, + { + // This hook is called async. If we would pass the transaction here, the hook can be + // called after the transaction is done #5460 + database: getDatabase(), + schema: this.schema, + accountability: this.accountability, + } + ); } return keys; @@ -620,16 +620,18 @@ export class ItemsService implements AbstractSer } if (opts?.emitEvents !== false) { - await emitter.emitAsync(`${this.eventScope}.delete.before`, { - event: `${this.eventScope}.delete.before`, - accountability: this.accountability, - collection: this.collection, - item: keys, - action: 'delete', - payload: null, - schema: this.schema, - database: this.knex, - }); + await emitter.emitFilter( + `${this.eventScope}.delete`, + keys, + { + collection: this.collection, + }, + { + database: this.knex, + schema: this.schema, + accountability: this.accountability, + } + ); } await this.knex.transaction(async (trx) => { @@ -659,18 +661,20 @@ export class ItemsService implements AbstractSer } if (opts?.emitEvents !== false) { - emitAsyncSafe(`${this.eventScope}.delete`, { - event: `${this.eventScope}.delete`, - accountability: this.accountability, - collection: this.collection, - item: keys, - action: 'delete', - payload: null, - schema: this.schema, - // This hook is called async. If we would pass the transaction here, the hook can be - // called after the transaction is done #5460 - database: getDatabase(), - }); + emitter.emitAction( + `${this.eventScope}.delete`, + { + payload: keys, + collection: this.collection, + }, + { + // This hook is called async. If we would pass the transaction here, the hook can be + // called after the transaction is done #5460 + database: getDatabase(), + schema: this.schema, + accountability: this.accountability, + } + ); } return keys; diff --git a/api/src/types/extensions.ts b/api/src/types/extensions.ts index 4b5f525e82..ba0ecb6f86 100644 --- a/api/src/types/extensions.ts +++ b/api/src/types/extensions.ts @@ -1,4 +1,4 @@ -import { ListenerFn } from 'eventemitter2'; +import { Accountability } from '@directus/shared/types'; import { Router } from 'express'; import { Knex } from 'knex'; import { Logger } from 'pino'; @@ -6,6 +6,7 @@ import env from '../env'; import * as exceptions from '../exceptions'; import * as services from '../services'; import { getSchema } from '../utils/get-schema'; +import { SchemaOverview } from './schema'; export type ExtensionContext = { services: typeof services; @@ -16,7 +17,25 @@ export type ExtensionContext = { getSchema: typeof getSchema; }; -type HookHandlerFunction = (context: ExtensionContext) => Record; +export type HookContext = { + database: Knex; + schema: SchemaOverview | null; + accountability: Accountability | null; +}; + +export type FilterHandler = (payload: any, meta: Record, context: HookContext) => any | Promise; +export type ActionHandler = (meta: Record, context: HookContext) => void | Promise; +export type InitHandler = (meta: Record) => void | Promise; +export type ScheduleHandler = () => void | Promise; + +type RegisterFunctions = { + filter: (event: string, handler: FilterHandler) => void; + action: (event: string, handler: ActionHandler) => void; + init: (event: string, handler: InitHandler) => void; + schedule: (cron: string, handler: ScheduleHandler) => void; +}; + +type HookHandlerFunction = (register: RegisterFunctions, context: ExtensionContext) => void; export type HookConfig = HookHandlerFunction; diff --git a/api/src/webhooks.ts b/api/src/webhooks.ts index c19e28609a..74366b56a9 100644 --- a/api/src/webhooks.ts +++ b/api/src/webhooks.ts @@ -1,14 +1,13 @@ import axios from 'axios'; -import { ListenerFn } from 'eventemitter2'; import getDatabase from './database'; import emitter from './emitter'; import logger from './logger'; -import { Webhook, WebhookHeader } from './types'; +import { ActionHandler, Webhook, WebhookHeader } from './types'; import { pick } from 'lodash'; import { WebhooksService } from './services'; import { getSchema } from './utils/get-schema'; -let registered: { event: string; handler: ListenerFn }[] = []; +let registered: { event: string; handler: ActionHandler }[] = []; export async function register(): Promise { unregister(); @@ -20,13 +19,13 @@ export async function register(): Promise { if (webhook.actions.includes('*')) { const event = 'items.*'; const handler = createHandler(webhook); - emitter.on(event, handler); + emitter.onAction(event, handler); registered.push({ event, handler }); } else { for (const action of webhook.actions) { const event = `items.${action}`; const handler = createHandler(webhook); - emitter.on(event, handler); + emitter.onAction(event, handler); registered.push({ event, handler }); } } @@ -35,13 +34,13 @@ export async function register(): Promise { export function unregister(): void { for (const { event, handler } of registered) { - emitter.off(event, handler); + emitter.offAction(event, handler); } registered = []; } -function createHandler(webhook: Webhook): ListenerFn { +function createHandler(webhook: Webhook): ActionHandler { return async (data) => { if (webhook.collections.includes('*') === false && webhook.collections.includes(data.collection) === false) return; diff --git a/docs/extensions/hooks.md b/docs/extensions/hooks.md index 76f8fb0eb4..bf659d7a0a 100644 --- a/docs/extensions/hooks.md +++ b/docs/extensions/hooks.md @@ -17,108 +17,145 @@ Custom hooks are dynamically loaded from within your extensions folder. By defau ## 2. Define the Event Next, you will want to define your event. You can trigger your custom hook with any of the platform's many API events. -System events are referenced with the format: +An event is defined by its type and its name. + +Event names consist of multiple scopes delimited by a dot: ``` -.(.) +.... // eg: items.create -// eg: files.upload -// eg: collections.* -// eg: users.update.before +// eg: users.update +// eg: auth.login +// eg: routes.custom.before ``` -### Scope +There are four event types to choose from. -The scope determines the API endpoint that is triggered. The `*` wildcard can also be used to include all scopes. +### Filter -::: tip System Scope +A filter event executes prior to the event being fired. This allows you to check and/or modify the event's payload +before it is processed. -Currently all system tables are available as event scopes except for `directus_migrations` and `directus_sessions`, -which don't have relevant endpoints or services. +It also allows you to cancel an event based on the logic within the hook. Below is an example of how you can cancel a +create event by throwing a standard Directus exception. + +```js +module.exports = function registerHook({ filter }, { exceptions }) { + const { InvalidPayloadException } = exceptions; + + filter('items.create', async (input) => { + if (LOGIC_TO_CANCEL_EVENT) { + throw new InvalidPayloadException(WHAT_IS_WRONG); + } + + return input; + }); +}; +``` + +The first parameter of the filter register function is the event name. The second parameter is the modifiable payload. +The third argument is an event-specific meta object. The fourth argument is a context object with the following +properties: + +- `database` — The current database transaction +- `schema` — The current API schema in use +- `accountability` — Information about the current user + +#### Available Events + +| Name | Payload | Meta | +| ----------------------------- | -------------------- | ---------------------------- | +| `request.not_found` | `false` | `request`, `response` | +| `request.error` | The request errors | -- | +| `database.error` | The database error | `client` | +| `auth.login` | The login payload | `status`, `user`, `provider` | +| `auth.jwt` | The auth token | `status`, `user`, `provider` | +| `(.)items.create` | The new item | `collection` | +| `(.)items.update` | The updated item | `keys`, `collection` | +| `(.)items.delete` | The keys of the item | `collection` | +| `.create` | The new item | `collection` | +| `.update` | The updated item | `keys`, `collection` | +| `.delete` | The keys of the item | `collection` | + +::: tip System Collections + +`` should be replaced with one of the system collection names `activity`, `collections`, `fields`, +`folders`, `permissions`, `presets`, `relations`, `revisions`, `roles`, `settings`, `users` or `webhooks`. ::: ### Action -Defines the triggering operation within the specified context (see chart below). The `*` wildcard can also be used to -include all actions available to the scope. +An action event executes after a certain event and receives some data related to the event. -### Before +The first parameter of the action register function is the event name. The second argument is an event-specific meta +object. The third argument is a context object with the following properties: -Many scopes (see chart below) support an optional `.before` suffix for running a _blocking_ hook prior to the event -being fired. This allows you to check and/or modify the event's payload before it is processed. +- `database` — The current database transaction +- `schema` — The current API schema in use +- `accountability` — Information about the current user -- `items.create.before` (Blocking) -- `items.create` (Non Blocking, also called 'after' implicitly) +#### Available Events -This also allows you to cancel an event based on the logic within the hook. Below is an example of how you can cancel a -create event by throwing a standard Directus exception. +| Name | Meta | +| ----------------------------- | --------------------------------------------------- | +| `server.start` | -- | +| `server.stop` | -- | +| `response` | `request`, `response`, `ip`, `duration`, `finished` | +| `auth.login` | `payload`, `status`, `user`, `provider` | +| `files.upload` | `payload`, `key`, `collection` | +| `(.)items.read` | `payload`, `query`, `collection` | +| `(.)items.create` | `payload`, `key`, `collection` | +| `(.)items.update` | `payload`, `keys`, `collection` | +| `(.)items.delete` | `payload`, `collection` | +| `.create` | `payload`, `key`, `collection` | +| `.update` | `payload`, `keys`, `collection` | +| `.delete` | `payload`, `collection` | -```js -module.exports = function registerHook({ exceptions }) { - const { InvalidPayloadException } = exceptions; +::: tip System Collections - return { - 'items.create.before': async function (input) { - if (LOGIC_TO_CANCEL_EVENT) { - throw new InvalidPayloadException(WHAT_IS_WRONG); - } +`` should be replaced with one of the system collection names `activity`, `collections`, `fields`, +`folders`, `permissions`, `presets`, `relations`, `revisions`, `roles`, `settings`, `users` or `webhooks`. - return input; - }, - }; -}; -``` +::: -### Event Format Options +### Init -| Scope | Actions | Before | -| -------------------- | ------------------------------------------------------------------ | ---------------- | -| `cron()` | [See below for configuration](#interval-cron) | No | -| `cli.init` | `before` and `after` | No | -| `server` | `start` and `stop` | Optional | -| `init` | | Optional | -| `routes.init` | `before` and `after` | No | -| `routes.custom.init` | `before` and `after` | No | -| `middlewares.init` | `before` and `after` | No | -| `request` | `not_found` | No | -| `response` | | No[1] | -| `database.error` | When a database error is thrown | No | -| `error` | | No | -| `auth` | `login`, `logout`[1], `jwt` and `refresh`[1] | Optional | -| `items` | `read`[2], `create`, `update` and `delete` | Optional | -| `activity` | `create`, `update` and `delete` | Optional | -| `collections` | `create`, `update` and `delete` | Optional | -| `fields` | `create`, `update` and `delete` | Optional | -| `files` | `upload`[2] | No | -| `folders` | `create`, `update` and `delete` | Optional | -| `permissions` | `create`, `update` and `delete` | Optional | -| `presets` | `create`, `update` and `delete` | Optional | -| `relations` | `create`, `update` and `delete` | Optional | -| `revisions` | `create`, `update` and `delete` | Optional | -| `roles` | `create`, `update` and `delete` | Optional | -| `settings` | `create`, `update` and `delete` | Optional | -| `users` | `create`, `update` and `delete` | Optional | -| `webhooks` | `create`, `update` and `delete` | Optional | +An init event executes at a certain point within the lifecycle of Directus. Init events can be used to inject logic into +internal services. -1 Feature Coming Soon\ -2 Doesn't support `.before` modifier +The first parameter of the init register function is the event name. The second parameter is an event-specific meta +object. -#### Interval (cron) +#### Available Events -Hooks support running on an interval through [`node-cron`](https://www.npmjs.com/package/node-cron). To set this up, -provide a cron statement in the event scope as follows: `cron()`, for example `cron(15 14 1 * *)` (at 14:15 -on day-of-month 1) or `cron(5 4 * * sun)` (at 04:05 on Sunday). See example below: +| Name | Meta | +| ---------------------- | --------- | +| `cli.before` | `program` | +| `cli.after` | `program` | +| `app.before` | `app` | +| `app.after` | `app` | +| `routes.before` | `app` | +| `routes.after` | `app` | +| `routes.custom.before` | `app` | +| `routes.custom.after` | `app` | +| `middlewares.before` | `app` | +| `middlewares.after` | `app` | + +### Schedule + +A schedule event executes at certain points in time. This is supported through +[`node-cron`](https://www.npmjs.com/package/node-cron). To set this up, provide a cron statement as the first argument +to the `schedule()` function, for example `schedule('15 14 1 * *', <...>)` (at 14:15 on day-of-month 1) or +`schedule('5 4 * * sun', <...>)` (at 04:05 on Sunday). See example below: ```js const axios = require('axios'); -module.exports = function registerHook() { - return { - 'cron(*/15 * * * *)': async function () { - await axios.post('http://example.com/webhook', { message: 'Another 15 minutes passed...' }); - }, - }; +module.exports = function registerHook({ schedule }) { + schedule('*/15 * * * *', async () => { + await axios.post('http://example.com/webhook', { message: 'Another 15 minutes passed...' }); + }); }; ``` @@ -129,26 +166,28 @@ Each custom hook is registered to its event scope using a function with the foll ```js const axios = require('axios'); -module.exports = function registerHook() { - return { - 'items.create': function () { - axios.post('http://example.com/webhook'); - }, - }; +module.exports = function registerHook({ action }) { + action('items.create', () => { + axios.post('http://example.com/webhook'); + }); }; ``` ## 4. Develop your Custom Hook -> Hooks can impact performance when not carefully implemented. This is especially true for `before` hooks (as these are +> Hooks can impact performance when not carefully implemented. This is especially true for filter hooks (as these are > blocking) and hooks on `read` actions, as a single request can result in a large amount of database reads. ### Register Function -The register function (eg: `module.exports = function registerHook()`) must return an object where the key is the event, -and the value is the handler function itself. +The `registerHook` function receives an object containing the type-specific register functions as the first parameter: -The `registerHook` function receives a context parameter with the following properties: +- `filter` — Listen for a filter event +- `action` — Listen for an action event +- `init` — Listen for an init event +- `schedule` — Execute a function at certain points in time + +The second parameter is a context object with the following properties: - `services` — All API internal services - `exceptions` — API exception objects that can be used for throwing "proper" errors @@ -157,54 +196,6 @@ The `registerHook` function receives a context parameter with the following prop - `env` — Parsed environment variables - `logger` — [Pino](https://github.com/pinojs/pino) instance. -### Event Handler Function - -The event handler function (eg: `'items.create': function()`) receives a context parameter with the following -properties: - -- `event` — Full event string -- `accountability` — Information about the current user -- `collection` — Collection that is being modified -- `item` — Primary key(s) of the item(s) being modified -- `action` — Action that is performed -- `payload` — Payload of the request -- `schema` - The current API schema in use -- `database` - Current database transaction - -::: tip Input - -The `items.*.before` hooks get the raw input payload as the first parameter, with the context parameter as the second -parameter. - -::: - -#### Items read - -In contrast to the other `items` events (`items.create`, `items.update`, `items.delete`) the `items.read` doesn't -receive the primary key(s) of the items but the query used: - -- `event` — Full event string -- `accountability` — Information about the current user -- `collection` — Collection that is being modified -- `query` — The query used to get the data -- `action` — Action that is performed -- `payload` — Payload of the request -- `schema` - The current API schema in use -- `database` - Current database transaction - -#### Auth - -The `auth` hooks have the following context properties: - -- `event` — Full event string -- `accountability` — Information about the current user -- `action` — Action that is performed -- `payload` — Payload of the request -- `provider` — The auth provider triggering the request -- `schema` - The current API schema in use -- `status` - One of `pending`, `success`, `fail` -- `user` - ID of the user that tried logging in/has logged in - ## 5. Restart the API To deploy your hook, simply restart the API by running: @@ -220,40 +211,44 @@ npx directus start ```js const axios = require('axios'); -module.exports = function registerHook({ services, exceptions }) { +module.exports = function registerHook({ filter, action }, { services, exceptions }) { const { MailService } = services; const { ServiceUnavailableException, ForbiddenException } = exceptions; - return { - // Force everything to be admin-only at all times - 'items.*': async function ({ item, accountability }) { - if (accountability.admin !== true) throw new ForbiddenException(); - }, - // Sync with external recipes service, cancel creation on failure - 'items.create.before': async function (input, { collection, schema }) { - if (collection !== 'recipes') return input; + // Sync with external recipes service, cancel creation on failure + filter('items.create', async (input, { collection }, { schema }) => { + if (collection !== 'recipes') return input; - const mailService = new MailService({ schema }); + const mailService = new MailService({ schema }); - try { - await axios.post('https://example.com/recipes', input); - await mailService.send({ - to: 'person@example.com', - template: { - name: 'item-created', - data: { - collection: collection, - }, + try { + await axios.post('https://example.com/recipes', input); + await mailService.send({ + to: 'person@example.com', + template: { + name: 'item-created', + data: { + collection: collection, }, - }); - } catch (error) { - throw new ServiceUnavailableException(error); - } + }, + }); + } catch (error) { + throw new ServiceUnavailableException(error); + } - input[0].syncedWithExample = true; + input[0].syncedWithExample = true; - return input; - }, - }; + return input; + }); + + // Force everything to be admin-only at all times + const adminOnly = async (_, { accountability }) => { + if (accountability.admin !== true) throw new ForbiddenException(); + }); + + action('items.create', adminOnly); + action('items.read', adminOnly); + action('items.update', adminOnly); + action('items.delete', adminOnly); }; ``` diff --git a/packages/extensions-sdk/templates/hook/javascript/src/index.js b/packages/extensions-sdk/templates/hook/javascript/src/index.js index ae6c61418c..6e38d7e34d 100644 --- a/packages/extensions-sdk/templates/hook/javascript/src/index.js +++ b/packages/extensions-sdk/templates/hook/javascript/src/index.js @@ -1,5 +1,9 @@ -export default () => ({ - 'items.create': () => { +export default ({ filter, action }) => { + filter('items.create', () => { + console.log('Creating Item!'); + }); + + action('items.create', () => { console.log('Item created!'); - }, -}); + }); +}; diff --git a/packages/extensions-sdk/templates/hook/typescript/src/index.ts b/packages/extensions-sdk/templates/hook/typescript/src/index.ts index 45165f4ed9..1ac340908a 100644 --- a/packages/extensions-sdk/templates/hook/typescript/src/index.ts +++ b/packages/extensions-sdk/templates/hook/typescript/src/index.ts @@ -1,7 +1,11 @@ import { defineHook } from '@directus/extensions-sdk'; -export default defineHook(() => ({ - 'items.create': () => { +export default defineHook(({ filter, action }) => { + filter('items.create', () => { + console.log('Creating Item!'); + }); + + action('items.create', () => { console.log('Item created!'); - }, -})); + }); +}); diff --git a/packages/shared/src/types/hooks.ts b/packages/shared/src/types/hooks.ts index 33a66ffbb6..13ed4a083e 100644 --- a/packages/shared/src/types/hooks.ts +++ b/packages/shared/src/types/hooks.ts @@ -1,5 +1,25 @@ +import { Knex } from 'knex'; +import { Accountability } from './accountability'; import { ApiExtensionContext } from './extensions'; -type HookHandlerFunction = (context: ApiExtensionContext) => Record void>; +type HookContext = { + database: Knex; + schema: Record | null; + accountability: Accountability | null; +}; + +type FilterHandler = (payload: any, meta: Record, context: HookContext) => any | Promise; +type ActionHandler = (meta: Record, context: HookContext) => void | Promise; +type InitHandler = (meta: Record) => void | Promise; +type ScheduleHandler = () => void | Promise; + +type RegisterFunctions = { + filter: (event: string, handler: FilterHandler) => void; + action: (event: string, handler: ActionHandler) => void; + init: (event: string, handler: InitHandler) => void; + schedule: (cron: string, handler: ScheduleHandler) => void; +}; + +type HookHandlerFunction = (register: RegisterFunctions, context: ApiExtensionContext) => void; export type HookConfig = HookHandlerFunction;