Skip to content

Latest commit

 

History

History
1334 lines (1041 loc) · 28.9 KB

File metadata and controls

1334 lines (1041 loc) · 28.9 KB

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

Tier 2 Plugin (separate package):

my-custom-plugin/
├── src/
│   └── index.ts        # Main implementation
├── test/
│   └── index.test.ts   # Unit tests
├── package.json
├── README.md
└── tsconfig.json

Step 3: Implement the Plugin

import type { PluginFunction } from '@prosdevlab/sdk-kit';

export const myPlugin: PluginFunction = (plugin, instance, config) => {
  // 1. Set namespace
  plugin.ns('my.plugin');
  
  // 2. Set defaults
  plugin.defaults({
    my: {
      plugin: {
        enabled: true,
        setting: 'default value',
      }
    }
  });
  
  // 3. Expose public API
  plugin.expose({
    myPlugin: {
      doSomething() {
        const setting = config.get('my.plugin.setting');
        plugin.emit('my-plugin:action', { setting });
      }
    }
  });
  
  // 4. Listen to lifecycle
  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:

  • @company/sdk-kit-plugin-analytics - Company analytics integration
  • @company/sdk-kit-plugin-auth - Company auth
  • @company/sdk-kit-plugin-crm - CRM integration

When to create:

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

Example: Tier 1 vs Tier 2

Tier 1 (Generic):

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,
        });
      }
    }
  });
};

Tier 2 (Company-Specific):

export const acmeAnalyticsPlugin: PluginFunction = (plugin, instance, config) => {
  plugin.expose({
    acmeAnalytics: {
      track(event: string, properties?: any) {
        // Company-specific: hardcoded endpoint, specific format
        instance.transport.send({
          url: 'https://api.acme.com/v1/collect',
          method: 'POST',
          data: {
            event_name: event,   // Acme format
            ...properties,
          },
        });
      }
    }
  });
};

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

  1. Review Examples - Check out the 6 essential plugins in packages/plugins/src/
  2. Start Small - Build a simple utility plugin first
  3. Follow Checklist - Use the implementation checklist from architecture patterns doc
  4. Ask Questions - Open issues or discussions
  5. Contribute - Submit PRs for Tier 1 plugins

Resources


Last Updated: December 18, 2025