Creating Plugins

Shared State and Requirements

Use shared state when plugins need to expose runtime objects to each other without adding another client.* property.

import { createPlugin, createSharedKey } from 'seyfert';

class LedgerService {
    readBalance(userId: string) {
        return 100;
    }
    close() {}
}

// curried call carries the value type on the key
export const ledgerKey = createSharedKey<LedgerService>()('ledger');

export const ledgerPlugin = createPlugin({
    name: 'ledger',
    register(api) {
        // share a lazily-built service under the key
        api.shared.set(ledgerKey, () => new LedgerService(), {
            // dispose runs when this plugin tears down
            dispose: (ledger) => ledger.close(),
        });
    },
});

The curried createSharedKey<LedgerService>()('ledger') carries the value type on the key. Shared values resolve lazily on first access:

// get returns undefined when missing
const ledger = client.shared.get(ledgerKey);
// unwrap throws when missing
const requiredLedger = client.shared.unwrap(ledgerKey);

get(...) returns undefined when a value is missing. unwrap(...) throws when it is missing. client.shared.has(key) checks without building it. The optional { dispose } runs when the owning plugin tears down.

Typed Shared Names

For app-wide shared names, augment RegisteredPluginShared.

import type { RegisteredPluginShared } from 'seyfert';

declare module 'seyfert' {
    // map an app-wide shared name to its value type
    interface RegisteredPluginShared {
        ledger: LedgerService;
    }
}

Now createSharedKey('ledger') is typed, and string-based access works too:

client.shared.get('ledger'); // LedgerService | undefined
client.shared.unwrap('ledger'); // LedgerService

Shared names are claimed once. If two plugins register the same name, Seyfert throws an attributed conflict — unless the later one passes { override: true }.

Requirements

Use imports when your plugin brings another plugin with it. Use requires when your plugin expects another plugin — or a shared capability — to already be present.

const storage = storagePlugin();

export const economy = createPlugin({
    name: 'economy',
    // bring storage along and order economy after it
    imports: [storage],
    requires: [
        // must be present
        'plugin:storage',
        // tolerated if absent
        { req: 'plugin:redis', optional: true },
        // present within a semver range
        { req: 'plugin:storage', range: '^2.0.0' },
        // a shared capability must exist
        { capability: ledgerKey },
    ],
    register(api) {
        // feature-check an optional dependency at runtime
        if (api.has('plugin:redis')) {
            api.diagnostics.warn('Redis support enabled.', { code: 'redis-enabled' });
        }
    },
});

Rules:

  • imports affects ordering and dedupes the same plugin instance.
  • requires validates that a requirement is present.
  • a missing required requirement throws during plugin resolution.
  • a missing optional requirement creates a diagnostics warning.
  • requirement shapes: plugin:<name>, { req, range } for a semver range, or { capability: sharedKey } for a shared value.

requires is not a priority system. If your plugin must run after another plugin and you own that relationship, put the dependency in imports.

Diagnostics Warnings

Use api.diagnostics.warn(...) for degraded but usable states.

register(api) {
    // record a warning for a degraded-but-usable state
    api.diagnostics.warn('Optional package "redis" was not found.', {
        code: 'missing-optional-peer',
    });
}

Warnings appear in client.plugins.diagnostics with plugin name, index, phase, code, and message.