npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ws-kit/adapters

v5.0.0

Published

Adapter implementations for WS-Kit: memory, Redis, Durable Objects, and more

Downloads

39

Readme

@ws-kit/adapters

Adapter implementations for WS-Kit: rate limiters for single-instance, multi-pod, and serverless deployments.

Overview

This package provides rate limiter adapters with identical interfaces but different implementations. Choose the adapter that matches your deployment model.

| Adapter | Use Case | Concurrency | Atomicity | | ------------------- | -------------------- | --------------------- | ---------- | | Memory | Dev, single instance | Mutex per key | Guaranteed | | Redis | Multi-pod production | Lua script | Guaranteed | | Durable Objects | Cloudflare Workers | Single-threaded shard | Guaranteed |

All adapters pass the same contract test suite to ensure correctness.

Installation

npm install @ws-kit/adapters @ws-kit/core

Adapters (Optional Dependencies)

Each adapter requires its runtime dependencies:

# Memory adapter (no extra dependencies)
npm install @ws-kit/adapters

# Redis adapter
npm install redis

# Cloudflare Durable Objects adapter
npm install --save-dev wrangler  # For types

Rate Limiter Adapters

Memory Adapter

Zero-dependency, in-process rate limiter using token bucket algorithm.

Best for: Development, single-instance deployments, testing.

import { memoryRateLimiter } from "@ws-kit/adapters/memory";

const limiter = memoryRateLimiter({
  capacity: 100, // Max tokens available
  tokensPerSecond: 10, // Refill rate
});

// Atomically consume tokens
const decision = await limiter.consume("user:123", 1);
if (decision.allowed) {
  // Process message
} else {
  // Backoff hint
  console.log(`Retry after ${decision.retryAfterMs}ms`);
}

Clock Injection (Testing)

const fakeTime = { current: Date.now() };

const limiter = memoryRateLimiter(
  { capacity: 10, tokensPerSecond: 1 },
  { clock: { now: () => fakeTime.current } },
);

// Consume tokens
await limiter.consume("user:1", 5);

// Time travel
fakeTime.current += 3000; // 3 seconds pass

// Tokens refilled
const result = await limiter.consume("user:1", 3);
expect(result.allowed).toBe(true);

Redis Adapter

Distributed rate limiter using Redis Lua scripts for atomicity.

Best for: Multi-pod production deployments, shared state across instances.

import { redisRateLimiter } from "@ws-kit/adapters/redis";
import { createClient } from "redis";

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

const limiter = redisRateLimiter(redisClient, {
  capacity: 100,
  tokensPerSecond: 10,
});

// Same interface as memory adapter
const decision = await limiter.consume("user:123", 1);

Features:

  • Single Lua script for atomicity (no race conditions)
  • Automatic TTL management (PEXPIRE) for stale bucket cleanup (auto-rounded to integer milliseconds)
  • Shared Redis connection for multiple limiters (memory-efficient)
  • Integer arithmetic for precision (no floating-point drift)
  • Server-authoritative time via redis.call('TIME') (no client clock manipulation)

Multi-Policy (Different Budgets for Different Operations):

const redisClient = createClient({ url: process.env.REDIS_URL });

// Both limiters share same connection
const cheap = redisRateLimiter(redisClient, {
  capacity: 200,
  tokensPerSecond: 100,
  prefix: "cheap:", // Namespace to prevent key collisions
});

const expensive = redisRateLimiter(redisClient, {
  capacity: 10,
  tokensPerSecond: 2,
  prefix: "expensive:",
});

// Independent rate limits for different operations
router.use(rateLimit({ limiter: cheap, cost: () => 1 }));
router.use(rateLimit({ limiter: expensive, cost: () => 5 }));

Durable Objects Adapter

Sharded rate limiter using Cloudflare Durable Objects.

Best for: Cloudflare Workers, serverless edge computing, geographically distributed deployments.

import { durableObjectRateLimiter } from "@ws-kit/adapters/cloudflare-do";

const limiter = durableObjectRateLimiter(env.RATE_LIMITER, {
  capacity: 100,
  tokensPerSecond: 10,
  shards: 128, // Distribute across 128 DOs (optional, default)
});

const decision = await limiter.consume("user:123", 1);

Features:

  • Sharding (FNV-1a hash) for load distribution (validated at creation time)
  • Persistent storage via Durable Object state
  • Automatic cleanup via mark-and-sweep (24h TTL)
  • Single-threaded per shard guarantees atomicity
  • Shard count validation (must be positive integer, prevents misconfiguration)

Setup (wrangler.toml):

[[durable_objects.bindings]]
name = "RATE_LIMITER"
class_name = "RateLimiterDO"

[[migrations]]
tag = "v1"
new_classes = ["RateLimiterDO"]

Rate Limiter Interface

All adapters implement this interface:

interface RateLimiter {
  /**
   * Atomically consume tokens from a rate limit bucket.
   */
  consume(key: string, cost: number): Promise<RateLimitDecision>;

  /**
   * Get the policy (capacity and refill rate) for this limiter.
   */
  getPolicy(): Policy;

  /**
   * Optional cleanup (close connections, clear timers, etc).
   */
  dispose?(): void;
}

type Policy = {
  capacity: number; // Max tokens available
  tokensPerSecond: number; // Refill rate
  prefix?: string; // Namespace prefix (optional)
};

type RateLimitDecision =
  | { allowed: true; remaining: number }
  | {
      allowed: false;
      remaining: number;
      retryAfterMs: number | null; // null if cost > capacity
    };

Policy Validation:

All adapters validate policy at creation time:

  • capacity must be ≥ 1 (throws: "Rate limit capacity must be ≥ 1")
  • tokensPerSecond must be > 0 (throws: "tokensPerSecond must be > 0")

Non-integer values are coerced to integers.

Durable Objects-Specific Validation:

  • shards must be a positive integer (throws: "Shard count must be a positive integer")
  • Default: 128 shards

Testing

Contract Tests

Every adapter must pass the shared contract test suite. Run tests for a specific adapter:

bun test packages/adapters/test/memory.test.ts
bun test packages/adapters/test/redis.test.ts

The contract test suite (packages/adapters/test/contract.ts) validates:

  • Basic consume (allowed/blocked)
  • Weighted costs
  • Cost > capacity (non-retryable)
  • Multi-key isolation
  • Concurrent requests (atomicity)
  • Refill over time
  • Prefix isolation
  • Disposal behavior

Using Contract Tests in Custom Adapters

To verify a custom adapter implementation, copy the contract test pattern from existing adapters:

// custom-adapter.test.ts
import type { RateLimiter } from "@ws-kit/core";
import { describe, expect, test } from "bun:test";

const testPolicy = { capacity: 10, tokensPerSecond: 1 };

describe("RateLimiter: Custom", () => {
  test("basic consume: allowed", async () => {
    const limiter = createMyCustomRateLimiter(testPolicy);
    const result = await limiter.consume("user:1", 1);
    expect(result.allowed).toBe(true);
    expect(result.remaining).toBe(9);
  });

  test("cost > capacity: not retryable", async () => {
    const limiter = createMyCustomRateLimiter(testPolicy);
    const result = await limiter.consume("user:1", 11);
    expect(result.allowed).toBe(false);
    expect(result.retryAfterMs).toBe(null);
  });

  // ... add more test cases
});

See packages/adapters/test/contract.ts for the full contract test suite that all adapters must pass.

Integration Tests

Test with middleware:

import { rateLimit, keyPerUserPerType } from "@ws-kit/middleware";
import { memoryRateLimiter } from "@ws-kit/adapters/memory";

test("middleware blocks rate-limited requests", async () => {
  const limiter = rateLimit({
    limiter: memoryRateLimiter({ capacity: 2, tokensPerSecond: 1 }),
    key: keyPerUserPerType,
    cost: () => 1,
  });

  let handlerCalls = 0;
  router.use(limiter);
  router.on(TestMessage, () => handlerCalls++);

  // First 2 requests allowed
  for (let i = 0; i < 2; i++) {
    await router._core.websocket.message(mockWs, JSON.stringify(...));
  }

  // 3rd request blocked
  await router._core.websocket.message(mockWs, JSON.stringify(...));

  expect(handlerCalls).toBe(2);
});

Implementing Custom Adapters

Create a custom adapter by implementing the RateLimiter interface:

import type { Policy, RateLimiter, RateLimitDecision } from "@ws-kit/core";

export function createMyRateLimiter(policy: Policy): RateLimiter {
  // Validate policy
  if (policy.capacity < 1) throw new Error("capacity must be ≥ 1");
  if (policy.tokensPerSecond <= 0)
    throw new Error("tokensPerSecond must be > 0");

  // Your storage backend
  const buckets = new Map<string, TokenBucket>();

  return {
    async consume(key: string, cost: number): Promise<RateLimitDecision> {
      const now = Date.now();
      const bucket = buckets.get(key) ?? {
        tokens: policy.capacity,
        lastRefill: now,
      };

      // 1. Refill based on elapsed time
      const elapsed = Math.max(0, (now - bucket.lastRefill) / 1000);
      bucket.tokens = Math.min(
        policy.capacity,
        bucket.tokens + elapsed * policy.tokensPerSecond,
      );
      bucket.lastRefill = now;

      // 2. Check cost availability
      if (bucket.tokens < cost) {
        const retryAfterMs =
          cost > policy.capacity
            ? null
            : Math.ceil(
                ((cost - bucket.tokens) / policy.tokensPerSecond) * 1000,
              );
        buckets.set(key, bucket);
        return {
          allowed: false,
          remaining: Math.floor(bucket.tokens),
          retryAfterMs,
        };
      }

      // 3. Deduct and persist
      bucket.tokens -= cost;
      buckets.set(key, bucket);
      return { allowed: true, remaining: Math.floor(bucket.tokens) };
    },

    getPolicy() {
      return policy;
    },

    dispose() {
      buckets.clear();
    },
  };
}

Key Implementation Details:

  1. Atomicity Guarantee — The consume() operation must be atomic per key:

    • Memory: Use mutex/lock per key
    • Redis: Single Lua script
    • Durable Objects: Single-threaded per shard
  2. Integer Arithmetic — Token counts use integer semantics:

    • Refill: floor(elapsed_seconds * tokensPerSecond)
    • Remaining: floor(bucket.tokens)
    • Cost: Validated as positive integer by middleware
  3. Clock Source — Each adapter owns its time source:

    • Memory: Date.now() or injected clock (for testing)
    • Redis: REDIS TIME (atomically in Lua)
    • Durable Objects: Date.now()
  4. Prefix Isolation — Optional prefix prevents key collisions:

    • Applied by adapter: prefixedKey = prefix ? prefix + key : key
    • Enables multiple rate limiters with independent namespaces

Performance Characteristics

| Adapter | Latency | Throughput | Storage | Notes | | ------- | ------- | ----------------- | ----------------- | ----------------- | | Memory | <1ms | Unlimited | ~200 bytes/bucket | Single-thread JS | | Redis | 2-5ms | Network-dependent | Network | Shared connection | | DO | 10-50ms | Per shard | Persistent | High availability |

Common Patterns

Tiered Rate Limiting

const free = memoryRateLimiter({ capacity: 100, tokensPerSecond: 10 });
const premium = memoryRateLimiter({ capacity: 1000, tokensPerSecond: 100 });

router.use((ctx, next) => {
  const tier = ctx.ws.data?.tier ?? "free";
  const limiter = tier === "premium" ? premium : free;
  return rateLimit({ limiter, key: perUserKey, cost: () => 1 })(ctx, next);
});

Cost-Based Differentiation

const limiter = memoryRateLimiter({ capacity: 100, tokensPerSecond: 10 });

router.use(
  rateLimit({
    limiter,
    key: keyPerUserPerType,
    cost: (ctx) => {
      if (ctx.type === "HEAVY_COMPUTE") return 20;
      if (ctx.type === "DATABASE_QUERY") return 5;
      return 1;
    },
  }),
);

Multi-Instance with Shared Redis

const redisClient = createClient({ url: process.env.REDIS_URL });

// Each pod gets independent limiter instances pointing to same Redis
const limiter = redisRateLimiter(redisClient, {
  capacity: 200,
  tokensPerSecond: 100,
});

// All pods share the same rate limit bucket
router.use(rateLimit({ limiter, key: keyPerUserPerType }));

Troubleshooting

"Rate limit cost must be a positive integer"

Cost function must return integer. Check:

  • No floating-point values (0.5, 1.5)
  • No zero or negative values
  • Deterministic (same input = same output)
// ❌ Wrong
cost: (ctx) => (ctx.ws.data?.isPremium ? 0.5 : 1);

// ✅ Correct
cost: () => 1;

"capacity must be ≥ 1"

Policy capacity must be at least 1. Check your configuration:

// ❌ Wrong
memoryRateLimiter({ capacity: 0, tokensPerSecond: 1 });

// ✅ Correct
memoryRateLimiter({ capacity: 10, tokensPerSecond: 1 });

"tokensPerSecond must be > 0"

Refill rate must be positive. For sub-1 rates, scale both values:

// ❌ Wrong
memoryRateLimiter({ capacity: 10, tokensPerSecond: 0.1 });

// ✅ Correct (represents 0.1 tokens/sec)
memoryRateLimiter({ capacity: 100, tokensPerSecond: 1 });

Architecture

See ADR-021: Adapter-First Architecture for design rationale and future patterns (deduplication, presence, sessions).

See Also