Plugins
Concepts

Plugins

Bundle middleware and hooks into reusable units

A plugin bundles middleware, hooks, and lifecycle callbacks into a single reusable unit. Instead of wiring the same middleware and hooks across every client, you package them once and pass the plugin to createClient.

import { createPlugin, createClient } from '@betternotify/core';
import { withTracing, withEventLogger } from '@betternotify/core/middlewares';

const observability = createPlugin({
  name: 'observability',
  middleware: [
    withTracing({ tracer }),
    withEventLogger({ sink }),
  ],
  hooks: {
    onError: ({ route, error, phase }) => {
      errorTracker.capture(error, { route, phase });
    },
  },
});

const mail = createClient({
  catalog,
  transportsByChannel: { email: transport },
  plugins: [observability],
});

Plugin shape

Prop

Type

How plugins compose

Plugin middleware and hooks merge with anything defined directly on the client. The ordering is:

  • Middleware: plugin middleware wraps outermost → procedure .use() wraps innermost → core. Multiple plugins compose in registration order — the first plugin is the outermost layer.
  • Hooks: plugin hooks run before client hooks. Within each group, handlers run in registration order.
  • Lifecycle: onCreate fires in registration order during createClient. onClose fires in reverse order when client.close() is called.
plugin middleware (outermost)
procedure middleware (.use)
render → transport → result

Building a plugin

Use createPlugin to define a plugin. It gives you full autocomplete and type inference — start with what you need and add fields as the scope grows:

import { createPlugin } from '@betternotify/core';
import { withRateLimit } from '@betternotify/core/middlewares';
import type { RateLimitStore } from '@betternotify/core';

const rateLimitPlugin = (store: RateLimitStore) =>
  createPlugin({
    name: 'rate-limit',
    middleware: [
      withRateLimit({ store, key: ({ args }) => String(args.to), max: 10, window: 60_000 }),
    ],
  });

Validation with onCreate

Use onCreate to verify that the catalog meets the plugin's requirements at startup:

const auditPlugin = createPlugin({
  name: 'audit',
  middleware: [withEventLogger({ sink: auditSink })],
  onCreate: ({ catalog }) => {
    if (catalog.routes.length === 0) {
      throw new Error('audit plugin requires at least one route');
    }
  },
});

Cleanup with onClose

Use onClose to flush buffers or close connections when the client shuts down:

import { createPlugin } from '@betternotify/core';
import { withEventLogger } from '@betternotify/core/middlewares';
import type { EventSink, SendEvent } from '@betternotify/core';

const bufferPlugin = (sink: EventSink) => {
  const buffer: SendEvent[] = [];
  let timer: ReturnType<typeof setInterval>;

  return createPlugin({
    name: 'buffered-events',
    middleware: [
      withEventLogger({
        sink: { write: async (event) => { buffer.push(event); } },
      }),
    ],
    onCreate: () => {
      timer = setInterval(async () => {
        const batch = buffer.splice(0);
        for (const event of batch) await sink.write(event);
      }, 5000);
    },
    onClose: async () => {
      clearInterval(timer);
      for (const event of buffer.splice(0)) await sink.write(event);
    },
  });
};

Multiple plugins

Pass multiple plugins as an array. They compose in order — first plugin wraps outermost:

const mail = createClient({
  catalog,
  transportsByChannel: { email: transport },
  plugins: [observability, rateLimitPlugin(store), auditPlugin],
});

Plugin hooks and client hooks never override each other — they all run. If a plugin hook throws, the error is isolated and routed to onError just like any other hook failure. See Hooks for error isolation details.

On this page