A comprehensive guide to building plugins for SDK Kit.
- Introduction
- Plugin Architecture
- Getting Started
- Plugin Types
- Core Patterns
- Testing
- Documentation
- Tier 1 vs Tier 2 Plugins
- Best Practices
- Examples
- FAQ
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.
- 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
- TypeScript knowledge
- Familiarity with SDK Kit core (
@prosdevlab/sdk-kit) - Understanding of functional programming basics
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:
-
plugin- Plugin capabilities:plugin.ns(namespace)- Set namespace for events/configplugin.defaults(config)- Set default configurationplugin.expose(api)- Add methods to SDK instanceplugin.emit(event, data)- Emit events
-
instance- SDK instance:instance.on(event, handler)- Listen to SDK events- Access to other plugins (if needed)
- Full SDK API
-
config- Configuration:config.get(path)- Read config values- Hierarchical with dot-notation (e.g.,
my.plugin.setting)
- Explicit dependencies - No hidden globals
- Type-safe - TypeScript knows what's available
- Testable - Can mock each capability
- Functional - No classes, no
thisbinding issues
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)?
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
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
});
};// 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 };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
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
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
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)
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
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
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)
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
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
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);
});
});- Happy Path - Basic functionality works
- Edge Cases - Boundary conditions, special values
- Error Handling - Graceful failure, fallbacks
- Events - All operations emit correct events
- Lifecycle - Init, ready, destroy hooks
- Configuration - Defaults, user config, runtime updates
- Integration - Works with other plugins
// 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
});- >90% line coverage - Aim high
- >80% branch coverage - Test all paths
- >95% function coverage - Test all exposed methods
# 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
MITCharacteristics:
- ✅ 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
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
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,
},
});
}
}
});
};Before writing code:
- Define the problem
- Design the API
- Identify dependencies
- Plan configuration
- List events
// ✅ 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
}
}// ✅ 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!// ✅ 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);
});/**
* 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- Aim for >90% coverage
- Test all error paths
- Test with different configs
- Test events
- Test lifecycle hooks
// ✅ Good: Single responsibility
export const storagePlugin // Handles storage
export const queuePlugin // Handles queuing
// ❌ Bad: Too many responsibilities
export const megaPlugin // Storage + Queue + Transport + Analyticsexport 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),
}
});
};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],
});
};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,
});
});
};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.
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
}A: Use jsdom:
// vitest.config.ts
export default {
test: {
environment: 'jsdom',
},
};A: Either works, but classes are convenient for:
- State management (fallback instances)
- Shared utilities
- Interface implementation
Functions are better for stateless utilities.
A: Return promises from exposed methods:
plugin.expose({
myPlugin: {
async fetchData() {
const response = await fetch('/api/data');
return response.json();
}
}
});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');A: Listen to sdk:destroy:
instance.on('sdk:destroy', () => {
// Clear timers
clearInterval(myTimer);
// Remove event listeners
window.removeEventListener('beforeunload', myHandler);
// Flush pending data
flush();
});- Review Examples - Check out the 6 essential plugins in
packages/plugins/src/ - Start Small - Build a simple utility plugin first
- Follow Checklist - Use the implementation checklist from architecture patterns doc
- Ask Questions - Open issues or discussions
- Contribute - Submit PRs for Tier 1 plugins
Last Updated: December 18, 2025