Skip to Content
SDK Kit is in active development. APIs may change.
Plugin Development Guide

Plugin Development Guide

A comprehensive guide to building plugins for SDK Kit.

Table of Contents


Introduction

Plugins are the heart of SDK Kit. They enable modular, reusable functionality that can be included or excluded at build time. This guide teaches you how to build high-quality plugins using battle-tested patterns.

What You’ll Learn

  • How to structure a plugin
  • Core design patterns
  • Testing strategies
  • When to create Tier 1 vs Tier 2 plugins
  • Best practices from 6 production plugins

Prerequisites

  • TypeScript knowledge
  • Familiarity with SDK Kit core (@prosdevlab/sdk-kit)
  • Understanding of functional programming basics

Plugin Architecture

The Plugin Function Signature

Every plugin is a pure function that receives three parameters:

import type { PluginFunction } from '@prosdevlab/sdk-kit'; export const myPlugin: PluginFunction = (plugin, instance, config) => { // Plugin implementation };

Parameters:

  1. plugin - Plugin capabilities:

    • plugin.ns(namespace) - Set namespace for events/config
    • plugin.defaults(config) - Set default configuration
    • plugin.expose(api) - Add methods to SDK instance
    • plugin.emit(event, data) - Emit events
  2. instance - SDK instance:

    • instance.on(event, handler) - Listen to SDK events
    • Access to other plugins (if needed)
    • Full SDK API
  3. config - Configuration:

    • config.get(path) - Read config values
    • Hierarchical with dot-notation (e.g., my.plugin.setting)

Why This Pattern?

  • Explicit dependencies - No hidden globals
  • Type-safe - TypeScript knows what’s available
  • Testable - Can mock each capability
  • Functional - No classes, no this binding issues

Getting Started

Step 1: Define Your Plugin’s Purpose

Before writing code, answer these questions:

  • What problem does this plugin solve?
  • Who will use it?
  • What should the API look like?
  • What configuration options are needed?
  • Is it Tier 1 (generic) or Tier 2 (specific)?

Step 2: Create the File Structure

Tier 1 Plugin (in monorepo):

packages/plugins/src/my-plugin/ ├── my-plugin.ts # Main implementation ├── index.ts # Barrel export ├── my-plugin.test.ts # Unit tests └── README.md # Documentation

Step 3: Implement the Plugin

Set namespace

Call plugin.ns() to establish your plugin’s event and config namespace.

plugin.ns('my.plugin');

Set defaults

Define default configuration values using plugin.defaults().

plugin.defaults({ my: { plugin: { enabled: true, setting: 'default value', } } });

Expose public API

Add methods to the SDK instance with plugin.expose().

plugin.expose({ myPlugin: { doSomething() { const setting = config.get('my.plugin.setting'); plugin.emit('my-plugin:action', { setting }); } } });

Listen to lifecycle

Hook into SDK events like sdk:ready and sdk:destroy.

instance.on('sdk:ready', () => { console.log('My plugin is ready!'); }); instance.on('sdk:destroy', () => { // Cleanup });

Step 4: Define Types

// Define the plugin's interface export interface MyPlugin { doSomething(): void; } // Define config interface export interface MyPluginConfig { my?: { plugin?: { enabled?: boolean; setting?: string; }; }; } // Export everything export { myPlugin };

Plugin Types

1. Storage-Like Plugins (CRUD Operations)

Use case: Managing data with multiple backends

Pattern: Backend abstraction with fallback

Structure:

plugin/ ├── index.ts # Main plugin ├── backends/ │ ├── types.ts # Backend interface │ ├── backend1.ts # Implementation 1 │ ├── backend2.ts # Implementation 2 │ └── fallback.ts # Fallback (e.g., memory) ├── plugin.test.ts └── README.md

Example: Storage Plugin

// backends/types.ts export interface StorageBackend { get(key: string): string | null; set(key: string, value: string): void; remove(key: string): void; clear(): void; isSupported(): boolean; } // backends/localStorage.ts export class LocalStorageBackend implements StorageBackend { private fallback: MemoryBackend | null = null; get(key: string): string | null { if (!this.isSupported()) { return this.getFallback().get(key); } try { return localStorage.getItem(key); } catch (error) { console.warn('localStorage failed:', error); return this.getFallback().get(key); } } isSupported(): boolean { try { const test = '__test__'; localStorage.setItem(test, test); localStorage.removeItem(test); return true; } catch { return false; } } private getFallback(): MemoryBackend { if (!this.fallback) { this.fallback = new MemoryBackend(); } return this.fallback; } // ... other methods } // storage.ts (main plugin) export const storagePlugin: PluginFunction = (plugin, instance, config) => { plugin.ns('storage'); const backends: Partial<Record<BackendType, StorageBackend>> = {}; function getBackend(type: BackendType): StorageBackend { if (!backends[type]) { switch (type) { case 'localStorage': backends[type] = new LocalStorageBackend(); break; case 'sessionStorage': backends[type] = new SessionStorageBackend(); break; // ... more backends } } return backends[type]!; } plugin.expose({ storage: { set(key, value, options) { const backend = getBackend(options?.backend ?? 'localStorage'); backend.set(key, JSON.stringify({ value })); plugin.emit('storage:set', { key, backend: options?.backend }); }, get(key, options) { const backend = getBackend(options?.backend ?? 'localStorage'); const raw = backend.get(key); if (!raw) return null; try { const { value } = JSON.parse(raw); plugin.emit('storage:get', { key, backend: options?.backend }); return value; } catch { return null; } }, // ... more methods } }); };

Key Patterns:

  • Backend abstraction via interface
  • Lazy initialization of backends
  • Graceful fallback to memory
  • Error handling at every step
  • Event emission for observability

2. Collector Plugins (Gather Data)

Use case: Collecting context/information

Pattern: Multiple collectors with unified output

Structure:

plugin/ ├── index.ts # Main plugin ├── collectors/ │ ├── collector1.ts # Collector 1 │ ├── collector2.ts # Collector 2 │ └── collector3.ts # Collector 3 ├── util/ # Shared utilities │ └── helpers.ts ├── plugin.test.ts └── README.md

Example: Context Plugin

// collectors/page.ts export function collectPage(): PageContext { if (typeof window === 'undefined') { return { url: '', path: '', query: {}, referrer: '', title: '', }; } const url = window.location.href; const parsed = new URL(url); return { url, path: parsed.pathname, query: Object.fromEntries(parsed.searchParams), referrer: document.referrer, title: document.title, }; } // context.ts (main plugin) export const contextPlugin: PluginFunction = (plugin, instance, config) => { plugin.ns('context'); let cache: Context | null = null; function collect(): Context { if (!cache) { cache = { page: collectPage(), device: collectDevice(), screen: collectScreen(), environment: collectEnvironment(), timestamp: Date.now(), }; } return cache; } function refresh(): void { cache = null; } plugin.expose({ context: { get: collect, getPage: () => collect().page, getDevice: () => collect().device, getScreen: () => collect().screen, getEnvironment: () => collect().environment, refresh, } }); };

Key Patterns:

  • Separate collectors for each data type
  • Caching to avoid redundant work
  • Manual refresh when needed
  • Graceful handling of missing APIs

3. Utility Plugins (Single Purpose)

Use case: Simple, focused functionality

Pattern: Single file with config

Structure:

plugin/ ├── poll.ts # Main plugin (all logic here) ├── index.ts # Barrel export ├── poll.test.ts └── README.md

Example: Poll Plugin

export const pollPlugin: PluginFunction = (plugin, instance, config) => { plugin.ns('poll'); plugin.defaults({ poll: { defaultInterval: 100, defaultTimeout: 5000, } }); const activePolls = new Set<ReturnType<typeof setInterval>>(); async function waitFor( condition: () => boolean, options?: { interval?: number; timeout?: number } ): Promise<boolean> { const interval = options?.interval ?? config.get('poll.defaultInterval'); const timeout = options?.timeout ?? config.get('poll.defaultTimeout'); // Try immediately if (condition()) { plugin.emit('poll:success', { immediate: true }); return true; } return new Promise((resolve) => { const startTime = Date.now(); const timer = setInterval(() => { if (condition()) { clearInterval(timer); activePolls.delete(timer); plugin.emit('poll:success', { elapsed: Date.now() - startTime }); resolve(true); } else if (Date.now() - startTime > timeout) { clearInterval(timer); activePolls.delete(timer); plugin.emit('poll:timeout', { elapsed: Date.now() - startTime }); resolve(false); } }, interval); activePolls.add(timer); }); } plugin.expose({ poll: { waitFor, element: (selector: string, options?) => waitFor(() => document.querySelector(selector) !== null, options), global: (name: string, options?) => waitFor(() => (window as any)[name] !== undefined, options), } }); instance.on('sdk:destroy', () => { activePolls.forEach(clearInterval); activePolls.clear(); }); };

Key Patterns:

  • Promise-based API
  • Immediate check (no delay)
  • Convenience methods
  • Lifecycle cleanup
  • Event emission

4. Orchestration Plugins (Coordinate Others)

Use case: Combining multiple plugins

Pattern: Depends on other plugins, adds coordination logic

Structure:

plugin/ ├── queue.ts # Main plugin ├── index.ts # Barrel export ├── queue.test.ts └── README.md

Example: Queue Plugin

export const queuePlugin: PluginFunction = (plugin, instance, config) => { plugin.ns('queue'); plugin.defaults({ queue: { maxSize: 20, flushInterval: 5000, persist: false, maxQueueSize: 100, } }); const state = { items: [] as QueueItem[], flushTimer: null as ReturnType<typeof setInterval> | null, }; function add(data: unknown): string { const item: QueueItem = { id: `${Date.now()}-${Math.random()}`, data, timestamp: Date.now(), retries: 0, }; state.items.push(item); plugin.emit('queue:add', item); // Auto-flush if maxSize reached const maxSize = config.get('queue.maxSize'); if (maxSize && state.items.length >= maxSize) { flush(); } // FIFO if maxQueueSize exceeded const maxQueueSize = config.get('queue.maxQueueSize'); if (maxQueueSize && state.items.length > maxQueueSize) { state.items.shift(); // Remove oldest } // Persist if enabled if (config.get('queue.persist') && instance.storage) { instance.storage.set('_sdk_queue', state.items); } return item.id; } function flush(): void { if (state.items.length === 0) return; const items = [...state.items]; state.items = []; plugin.emit('queue:flush', { items }); // Clear persisted queue if (config.get('queue.persist') && instance.storage) { instance.storage.remove('_sdk_queue'); } } plugin.expose({ queue: { add, flush, size: () => state.items.length, clear: () => { state.items = []; if (instance.storage) { instance.storage.remove('_sdk_queue'); } }, } }); // Start flush timer instance.on('sdk:ready', () => { const interval = config.get('queue.flushInterval'); if (interval) { state.flushTimer = setInterval(flush, interval); } // Restore from storage if (config.get('queue.persist') && instance.storage) { const persisted = instance.storage.get('_sdk_queue'); if (Array.isArray(persisted)) { state.items = persisted; } } }); // Cleanup instance.on('sdk:destroy', () => { if (state.flushTimer) { clearInterval(state.flushTimer); } flush(); // Flush remaining items }); // Flush on page unload if (typeof window !== 'undefined') { window.addEventListener('beforeunload', flush); } };

Key Patterns:

  • Depends on Storage plugin (optional)
  • Auto-flush based on size or time
  • Persistence across page reloads
  • Lifecycle integration
  • Multiple triggers (size, time, destroy, unload)

Core Patterns

Pattern 1: Capability Injection

Problem: How do plugins access SDK features?

Solution: Pass capabilities as function parameters

export const myPlugin: PluginFunction = (plugin, instance, config) => { // 'plugin', 'instance', 'config' are injected capabilities plugin.ns('my.plugin'); // Use plugin capabilities instance.on('sdk:ready', () =>{}); // Use SDK instance config.get('my.plugin.setting'); // Use config };

Benefits:

  • Explicit dependencies (no hidden globals)
  • Type-safe
  • Testable (mock each capability)
  • Tree-shakeable

Pattern 2: Lazy Initialization

Problem: Creating all resources upfront is wasteful

Solution: Create resources only when needed

const backends: Partial<Record<BackendType, Backend>> = {}; function getBackend(type: BackendType): Backend { if (!backends[type]) { backends[type] = createBackend(type); // Create on first use } return backends[type]!; }

Benefits:

  • Faster initialization
  • Lower memory usage
  • Supports tree-shaking
  • Only pay for what you use

Pattern 3: Graceful Degradation

Problem: Native APIs can fail in edge cases

Solution: Always have a working fallback

try { localStorage.setItem(key, value); } catch (error) { console.warn('localStorage failed, falling back to memory'); this.fallback.set(key, value); // Always works }

Benefits:

  • Never throw errors to user code
  • SDK continues to function
  • Users don’t see failures
  • Better UX in edge cases (private mode, quota exceeded)

Pattern 4: Metadata Wrapping

Problem: Native APIs don’t support features we need

Solution: Wrap values with metadata

interface StoredValue<T> { value: T; expires?: number; // Add TTL support } // Store const stored = { value: data, expires: Date.now() + 3600000 }; backend.set(key, JSON.stringify(stored)); // Retrieve const stored = JSON.parse(backend.get(key)); if (stored.expires && Date.now() > stored.expires) { return null; // Expired! } return stored.value;

Benefits:

  • Add features transparently
  • Backward compatible
  • No API changes needed
  • Flexible metadata

Pattern 5: Event-Driven Observability

Problem: Users need visibility into plugin behavior

Solution: Emit events for all significant operations

plugin.emit('storage:set', { key, value, backend }); plugin.emit('storage:get', { key, backend }); plugin.emit('storage:expired', { key, backend }); // Users can observe sdk.on('storage:*', (event, data) => { console.log('Storage event:', event, data); });

Benefits:

  • Users can observe behavior
  • Enables debugging
  • Supports monitoring/analytics
  • Loose coupling between plugins

Testing

Test Structure

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { SDK } from '@prosdevlab/sdk-kit'; import { myPlugin, type MyPlugin } from './my-plugin'; interface SDKWithPlugin extends SDK { myPlugin: MyPlugin; } describe('My Plugin', () => { let sdk: SDKWithPlugin; beforeEach(() => { const newSdk = new SDK(); newSdk.use(myPlugin); sdk = newSdk as SDKWithPlugin; }); afterEach(() => { sdk.destroy(); }); it('should do something', async () => { await sdk.init(); sdk.myPlugin.doSomething(); expect(true).toBe(true); }); });

Test Coverage Categories

  1. Happy Path - Basic functionality works
  2. Edge Cases - Boundary conditions, special values
  3. Error Handling - Graceful failure, fallbacks
  4. Events - All operations emit correct events
  5. Lifecycle - Init, ready, destroy hooks
  6. Configuration - Defaults, user config, runtime updates
  7. Integration - Works with other plugins

Testing Best Practices

// 1. Use jsdom for browser APIs // vitest.config.ts export default { test: { environment: 'jsdom', }, }; // 2. Mock console warnings beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); }); // 3. Use fake timers for TTL/delays it('should expire after TTL', () => { vi.useFakeTimers(); sdk.storage.set('key', 'value', { ttl: 10 }); expect(sdk.storage.get('key')).toBe('value'); vi.advanceTimersByTime(11000); expect(sdk.storage.get('key')).toBeNull(); vi.useRealTimers(); }); // 4. Test events it('should emit events', () => { const handler = vi.fn(); sdk.on('my-plugin:action', handler); sdk.myPlugin.doSomething(); expect(handler).toHaveBeenCalledWith({ /* event data */ }); }); // 5. Test with different configs it('should respect user config', async () => { await sdk.init({ my: { plugin: { setting: 'custom' } } }); // Plugin should use 'custom' setting });

Target Coverage

  • >90% line coverage - Aim high
  • >80% branch coverage - Test all paths
  • >95% function coverage - Test all exposed methods

Documentation

Plugin README Template

# My Plugin Brief description of what the plugin does. ## Features - Feature 1 - Feature 2 - Feature 3 ## Installation \`\`\`bash npm install @prosdevlab/sdk-kit @prosdevlab/sdk-kit-plugins \`\`\` ## Usage \`\`\`typescript import { SDK } from '@prosdevlab/sdk-kit'; import { myPlugin } from '@prosdevlab/sdk-kit-plugins'; const sdk = new SDK(); sdk.use(myPlugin); await sdk.init({ my: { plugin: { setting: 'value' } } }); // Use the plugin sdk.myPlugin.doSomething(); \`\`\` ## API ### \`doSomething()\` Description of what this method does. **Parameters:** - \`param1\` (type) - Description - \`param2\` (type, optional) - Description **Returns:** Type - Description **Example:** \`\`\`typescript sdk.myPlugin.doSomething(param1, param2); \`\`\` ## Configuration | Option | Type | Default | Description | |--------|------|---------|-------------| | \`setting\` | string | \`'default'\` | Description | ## Events ### \`my-plugin:action\` Emitted when... Description. **Payload:** \`\`\`typescript { key: string; value: any; } \`\`\` ## Browser Compatibility - Chrome 90+ - Firefox 88+ - Safari 14+ - Edge 90+ ## License MIT

Tier 1 vs Tier 2 Plugins

Tier 1: Generic, Battle-Tested (Monorepo)

Characteristics:

  • No company-specific logic
  • No hardcoded endpoints
  • Configurable for any use case
  • Lives in @prosdevlab/sdk-kit-plugins

Examples:

  • Storage (localStorage, cookies)
  • Context (URL, user agent)
  • Transport (HTTP, beacon)
  • Queue (batching)
  • Poll (async conditions)
  • Consent (GDPR)

When to create:

  • Solves a common problem
  • Useful for any SDK
  • No company-specific assumptions
  • Can be maintained long-term

Tier 2: Custom/Community (Separate Packages)

Characteristics:

  • Company-specific logic
  • Hardcoded endpoints/formats
  • Specific to one product
  • Lives in separate npm package (e.g., @company/sdk-kit-plugin-name)

Examples:

  • @prosdevlab/sdk-kit-plugin-lytics - Lytics API integration
  • @company/sdk-kit-plugin-analytics - Company analytics
  • @company/sdk-kit-plugin-auth - Company auth

When to create:

  • Company-specific use case
  • Builds on Tier 1 plugins
  • Different release cycle
  • Product-specific features

Example: Tier 1 vs Tier 2

export const transportPlugin: PluginFunction = (plugin, instance, config) => { plugin.expose({ transport: { send(request: TransportRequest) { // Generic HTTP transport - works with ANY backend return fetch(request.url, { method: request.method, body: request.data, }); } } }); };

Best Practices

1. Start with Design

Before writing code:

  • Define the problem
  • Design the API
  • Identify dependencies
  • Plan configuration
  • List events

2. Follow Functional Patterns

// Good: Pure function export const myPlugin: PluginFunction = (plugin, instance, config) => { // No state outside function }; // Bad: Class with state export class MyPlugin { private state = {}; register(sdk: SDK) { // Hidden state, harder to test } }

3. Handle Errors Gracefully

// Good: Catch and fallback try { localStorage.setItem(key, value); } catch { this.fallback.set(key, value); } // Bad: Let errors propagate localStorage.setItem(key, value); // Throws in private mode!

4. Emit Events Liberally

// Good: Events for observability plugin.emit('storage:set', { key, value }); plugin.emit('storage:get', { key }); plugin.emit('storage:expired', { key }); // Users can monitor sdk.on('storage:*', (event, data) => { console.log(event, data); });

5. Document Thoroughly

/** * Store a value with optional TTL * * @param key - Storage key * @param value - Value to store (any JSON-serializable type) * @param options - Storage options * @param options.backend - Backend to use (default: localStorage) * @param options.ttl - Time to live in seconds * * @example * sdk.storage.set('user', { id: 123 }, { ttl: 3600 }); */ set<T>(key: string, value: T, options?: StorageOptions): void

6. Test Thoroughly

  • Aim for >90% coverage
  • Test all error paths
  • Test with different configs
  • Test events
  • Test lifecycle hooks

7. Keep Plugins Focused

// Good: Single responsibility export const storagePlugin // Handles storage export const queuePlugin // Handles queuing // Bad: Too many responsibilities export const megaPlugin // Storage + Queue + Transport + Analytics

Examples

Example 1: Simple Utility Plugin

export const timestampPlugin: PluginFunction = (plugin, instance, config) => { plugin.ns('timestamp'); plugin.expose({ timestamp: { now: () => Date.now(), iso: () => new Date().toISOString(), unix: () => Math.floor(Date.now() / 1000), } }); };

Example 2: Plugin with Configuration

export const loggerPlugin: PluginFunction = (plugin, instance, config) => { plugin.ns('logger'); plugin.defaults({ logger: { level: 'info', prefix: '[SDK]', } }); const logs: string[] = []; function log(level: string, message: string, data?: any) { const prefix = config.get('logger.prefix'); const configLevel = config.get('logger.level'); // Check if this level should be logged const levels = ['debug', 'info', 'warn', 'error']; if (levels.indexOf(level) < levels.indexOf(configLevel)) { return; // Skip } const formatted = `${prefix} [${level.toUpperCase()}] ${message}`; logs.push(formatted); console[level](formatted, data); plugin.emit('logger:log', { level, message, data }); } plugin.expose({ debug: (msg, data?) => log('debug', msg, data), info: (msg, data?) => log('info', msg, data), warn: (msg, data?) => log('warn', msg, data), error: (msg, data?) => log('error', msg, data), getLogs: () => [...logs], }); };

Example 3: Plugin Depending on Another Plugin

export const analyticsPlugin: PluginFunction = (plugin, instance, config) => { plugin.ns('analytics'); plugin.defaults({ analytics: { endpoint: 'https://api.example.com/events', attachContext: true, } }); plugin.expose({ analytics: { track(event: string, properties?: any) { // Use queue plugin (if available) if (instance.queue) { instance.queue.add({ event, properties }); } }, } }); // Flush queue via transport instance.on('queue:flush', async ({ items }) => { const endpoint = config.get('analytics.endpoint'); const attachContext = config.get('analytics.attachContext'); const payload = { events: items, context: attachContext && instance.context ? instance.context.get() : undefined, }; await instance.transport.send({ url: endpoint, method: 'POST', data: payload, }); }); };

FAQ

Q: Should I create a Tier 1 or Tier 2 plugin?

A: Ask yourself:

  • Is it useful for any SDK? → Tier 1
  • Is it company-specific? → Tier 2

When in doubt, start with Tier 2. You can always extract generic parts into Tier 1 later.

Q: Can plugins depend on other plugins?

A: Yes, but:

  • Check if the plugin exists before using it
  • Make dependencies optional when possible
  • Document dependencies clearly
if (instance.storage) { // Use storage plugin } else { // Fallback behavior }

Q: How do I test browser APIs in Node.js?

A: Use jsdom:

// vitest.config.ts export default { test: { environment: 'jsdom', }, };

Q: Should I use classes or functions for backends?

A: Either works, but classes are convenient for:

  • State management (fallback instances)
  • Shared utilities
  • Interface implementation

Functions are better for stateless utilities.

Q: How do I handle async operations in plugins?

A: Return promises from exposed methods:

plugin.expose({ myPlugin: { async fetchData() { const response = await fetch('/api/data'); return response.json(); } } });

Q: Can I modify config at runtime?

A: Users can with sdk.set(). Your plugin reads the latest value:

// Plugin reads config const setting = config.get('my.plugin.setting'); // User updates at runtime sdk.set('my.plugin.setting', 'new value');

Q: How do I clean up resources on destroy?

A: Listen to sdk:destroy:

instance.on('sdk:destroy', () => { // Clear timers clearInterval(myTimer); // Remove event listeners window.removeEventListener('beforeunload', myHandler); // Flush pending data flush(); });

Next Steps

Review Examples

Check out the 6 essential plugins in the plugins package source.

Start Small

Build a simple utility plugin first to get familiar with the patterns.

Follow Patterns

Use the implementation patterns from this guide as templates.

Ask Questions

Open issues or discussions on GitHub if you get stuck.

Contribute

Submit PRs for Tier 1 plugins that could benefit the community.

Resources


Last Updated: January 1, 2026

Last updated on