ADR-031: Plugin-Adapter Architecture
Status: Final Date: 2025-11-14 References: ADR-028 (Plugin System), ADR-021 (Adapter-First Architecture), ADR-025 (Validator Plugins)
Context
As the plugin system matured (ADR-028), a new question emerged: where should stateful features like pub/sub and rate-limiting live?
The Challenge
Three candidates competed for ownership:
- In core packages (
@ws-kit/core) — Always available, zero config - In validator packages (
@ws-kit/zod,@ws-kit/valibot) — With validation plugins - In separate packages (
@ws-kit/pubsub,@ws-kit/rate-limit) — One feature per package
Each had trade-offs:
| Option | Pros | Cons |
|---|---|---|
| Core | Always available, discoverable | Bloats core; some users don't need it |
| Validators | Ships with validation | Couples unrelated features; requires duplication in Zod/Valibot |
| Separate | Clear ownership, opt-in | Multiple imports, more packages to manage |
The Real Problem
The real issue wasn't where to put features, but where to put backends:
- Pub/Sub works the same on a single server (memory) or Redis (distributed)
- Rate-limiting works the same locally (token bucket in memory) or globally (Redis Streams)
- Users should swap adapters without rewriting code
We needed a plugin-adapter split: plugins = framework features (live in core), adapters = backend implementations (live in separate packages).
Decision
Core Principle: Plugin vs Adapter
Plugins = Framework features, adapter-agnostic:
withMessaging()— Unicast and broadcastwithRpc()— Request-response with streamingwithPubSub()— Pub/sub pattern (any backend)withRateLimit()— Rate limiting (any backend)
Adapters = Backend implementations:
- Memory adapters in
@ws-kit/core(defaults, zero config) - Redis adapters in
@ws-kit/redis - Cloudflare adapters in
@ws-kit/cloudflare - Custom adapters anywhere (user-provided)
Package Structure
@ws-kit/core/src
├── capabilities/ # Capability contracts (core only)
├── plugin/ # Plugin system (definePlugin, etc)
├── context/ # Context types and base context
├── core/ # Router implementation
└── index.ts # Core API exports
@ws-kit/plugins/src # Framework feature plugins (validator-agnostic)
├── messaging/
│ ├── index.ts # withMessaging() plugin
│ └── types.ts # SendOptions, etc.
├── rpc/
│ ├── index.ts # withRpc() plugin
│ └── types.ts # ReplyOptions, ProgressOptions, RpcContext
└── index.ts # Re-exports: withMessaging, withRpc
@ws-kit/zod & @ws-kit/valibot/src
└── index.ts # Re-exports core plugins (convenience)
@ws-kit/memory/src # In-memory adapters (development/testing)
├── pubsub.ts # memoryPubSub() - in-memory implementation
├── limiter.ts # memoryRateLimiter() - in-memory token bucket
└── index.ts # Re-exports: all memory adapters
@ws-kit/redis/src
├── pubsub.ts # redisPubSub(client) adapter
├── rate-limit.ts # redisRateLimiter(client) adapter
└── index.ts
@ws-kit/cloudflare/src
├── pubsub.ts # cloudflarePubSub(durableObjects) adapter
├── rate-limit.ts # cloudflareRateLimiter(env) adapter
└── index.tsKey Design:
- Plugins (
@ws-kit/plugins) contain framework APIs—stateless or adapter-agnostic - Memory adapters (
@ws-kit/memory) are bundled defaults for zero-config dev - External adapters (
@ws-kit/redis,@ws-kit/cloudflare) are separate packages for production - Separate plugins package—all core plugins live in
@ws-kit/pluginsfor clean separation
Why a separate plugins package?
- ✅ All plugins centralized and easy to discover (single import source:
@ws-kit/plugins) - ✅ Simpler core package (core owns routing, adapters own adapter interfaces)
- ✅ Cleaner mental model (core = framework, plugins = capabilities)
- ✅ Extensibility (users can add plugins without core bloat)
- ⚠️ Trade-off: One more package to import from (but re-exported by validators for convenience)
Rationale
1. Plugins in Core (High-Use Framework Features)
Why withPubSub() and withRateLimit() belong in core:
- Validator-agnostic: Work with Zod, Valibot, or custom validators
- High usage: ~60% of real-time apps need pub/sub; ~40% need rate-limiting
- No backend coupling: Plugin defines API; adapter provides backend
- Small footprint: ~180 LOC combined (plugins only, no adapter logic)
- Discoverability: Users expect framework features in core
What lives in plugins:
withPubSub()→ context API (ctx.publish()), middleware integrationwithRateLimit()→ middleware, rate-limit checking logic- No backend-specific code (Redis Lua scripts, Cloudflare APIs, etc.)
2. Memory Adapters in Core (Zero-Config Defaults)
Why default adapters belong in core:
- Development experience: Works immediately, no Redis setup needed
- Testing: Apps pass tests without external infrastructure
- Learning: Beginners can focus on logic, not configuration
- Backward compatible: Apps transition from dev → prod by swapping adapters
Examples:
// In-memory pub/sub (development)
export function memoryPubSub(): PubSubAdapter {
const subscriptions = new Map<string, Set<string>>();
return {
subscribe: (clientId, topic) => {
if (!subscriptions.has(topic)) subscriptions.set(topic, new Set());
subscriptions.get(topic)!.add(clientId);
},
publish: (topic, message) => {
const subscribers = subscriptions.get(topic) || new Set();
return { matched: subscribers.size, capability: "exact" };
},
// ... unsubscribe, etc.
};
}
// Token-bucket rate limiter (development)
export function memoryRateLimiter(): RateLimiterAdapter {
const buckets = new Map<string, { tokens: number; lastRefill: number }>();
return {
consume: async (key, count) => {
// Token bucket algorithm
},
};
}Size impact: ~110 LOC in core (negligible)
3. External Adapters in Separate Packages (Optional Production)
Why Redis/Cloudflare adapters stay separate:
- Heavy dependencies:
@ws-kit/redisrequires redis client (~50KB) - Platform-specific: Cloudflare APIs only work in Workers environment
- Versioning: Adapters evolve independently from core
- Choice: Apps without these don't pay bundle cost
Examples:
// @ws-kit/redis
export function redisPubSub(client: RedisClient): PubSubAdapter {
return {
subscribe: (clientId, topic) => client.sadd(keyOf(topic), clientId),
publish: (topic, message) => client.publish(keyOf(topic), JSON.stringify(message)),
};
}
// @ws-kit/cloudflare
export function cloudflarePubSub(env: CloudflareEnv): PubSubAdapter {
return {
subscribe: (clientId, topic) => env.DURABLE_OBJECTS.stub.request(...),
publish: (topic, message) => env.DURABLE_OBJECTS.stub.request(...),
};
}Benefits
For Users
| Scenario | Experience |
|---|---|
| Development | withPubSub() uses memory adapter by default; works immediately |
| Testing | No Redis needed; in-memory adapters sufficient |
| Production | Swap to redisPubSub(redis) by changing one line |
| Scaling | Seamless move from single-server to distributed without code changes |
For Maintainers
| Benefit | Details |
|---|---|
| No duplication | Plugins defined once in core; adapters in separate packages |
| Clear ownership | Each adapter package owns its backend (Redis, Cloudflare, etc.) |
| Independent versioning | Core, validators, and adapters can release on separate schedules |
| Extensibility | Users can implement custom adapters without forking |
Implementation
Phase 1: Extract Shared Logic (Unblock Plugins)
Move from validator plugins to core (@ws-kit/core/src/):
SendOptions,ReplyOptions,ProgressOptionstypes- Meta utilities:
sanitizeMeta(),inheritCorrelationId() - Throttling utilities
- RPC guards (one-shot reply logic)
Risk: Low—just moving existing code
Phase 2: Create Core Plugins
Implemented in @ws-kit/plugins/src/:
messaging/index.ts+types.ts—withMessaging()(~100 LOC)rpc/index.ts+types.ts—withRpc()(~120 LOC)
Additional plugins in feature packages:
@ws-kit/pubsub/src/—withPubSub()(~100 LOC)@ws-kit/rate-limit/src/—withRateLimit()(~80 LOC)
Each plugin gets its own subdirectory with implementation and types.
Risk: Low—existing logic refactored from validators
Phase 3: Create Memory Adapters
Add to @ws-kit/memory/src/:
pubsub.ts—memoryPubSub()adapter (~50 LOC)limiter.ts—memoryRateLimiter()adapter (~60 LOC)
Memory adapters implement the adapter interfaces defined in their corresponding plugins.
Risk: Low—simple in-memory implementations
Phase 4: External Adapters (Parallel)
Create or update external packages:
@ws-kit/redis—redisPubSub(),redisRateLimiter()@ws-kit/cloudflare—cloudflarePubSub(),cloudflareRateLimiter()
Risk: Low—independent from core
Phase 5: Update Validators
Refactor @ws-kit/zod and @ws-kit/valibot:
- Import plugins from core instead of implementing
- Re-export core plugins for convenience
Risk: Low—non-breaking for public API
Usage Examples
Development (Zero Config)
import { createRouter, withZod } from "@ws-kit/zod";
import { withPubSub } from "@ws-kit/pubsub";
import { memoryPubSub } from "@ws-kit/memory";
const router = createRouter()
.plugin(withZod())
.plugin(withPubSub({ adapter: memoryPubSub() })); // ✅ Zero-config memory adapterProduction (Redis Adapters)
import { createRouter, withZod } from "@ws-kit/zod";
import { withPubSub } from "@ws-kit/pubsub";
import { redisPubSub } from "@ws-kit/redis";
const router = createRouter()
.plugin(withZod())
.plugin(
withPubSub({
adapter: redisPubSub(redis), // ✅ Swap adapter
}),
);Cloudflare Workers
import { withPubSub } from "@ws-kit/pubsub";
import { cloudflarePubSub } from "@ws-kit/cloudflare";
const router = createRouter()
.plugin(withZod())
.plugin(
withPubSub({
adapter: cloudflarePubSub(env.DURABLE_OBJECTS), // Cloudflare DO
}),
);Adapter Interface Contract
PubSubAdapter
export interface PubSubAdapter {
subscribe(clientId: string, topic: string): Promise<void>;
unsubscribe(clientId: string, topic: string): Promise<void>;
publish(topic: string, message: Message): Promise<PublishResult>;
list(clientId: string): Promise<string[]>;
}RateLimiterAdapter
export interface RateLimiterAdapter {
consume(
key: string,
tokens: number,
): Promise<{ ok: boolean; retryAfterMs?: number }>;
reset(key: string): Promise<void>;
}Consequences
Positive
- ✅ Zero-config development:
withPubSub()works immediately - ✅ Simple production setup: One-line adapter swap
- ✅ No code changes: Dev code works unchanged in production
- ✅ Clear ownership: Each package has one job
- ✅ Extensible: Users can implement custom adapters
- ✅ No duplication: Plugins in core, adapters separate
- ✅ Tree-shakeable: Unused adapters not included in bundle
Negative
- ❌ More packages: Users manage core + validators + adapters
- ❌ Adapter discovery: Finding available adapters requires looking at docs
- ❌ Custom adapters: Users must implement adapter interface (but it's small)
Trade-offs
| Aspect | Dev Experience | Production Complexity |
|---|---|---|
| Single package | ❌ All features bundled | ✅ Easy deployment |
| Plugin-adapter split | ✅ Zero-config, swap adapters | ✅ Explicit, flexible |
Future Considerations
- Adapter registry: Docs/tooling to discover available adapters
- More adapters: Kafka, RabbitMQ, AWS SNS/SQS, etc.
- Adapter market: Community-maintained adapters
- Auto-selection: Smart adapter choice based on environment (dev vs prod)
References
- ADR-028: Plugin Architecture - Type-safe plugin system foundation
- ADR-021: Adapter-First Architecture - Original adapter pattern
- docs/specs/plugins.md — Comprehensive plugin reference
- docs/specs/adapters.md — Adapter interface specifications