@inkly/ratelimit
v0.0.0
Published
Per-socket token-bucket rate limiting for inkly.
Readme
@inkly/ratelimit
Per-socket token-bucket rate limiting for inkly.
Protects your server against message floods and ensures fair per-socket throughput without dropping the connection. Clients that exceed the limit receive an err frame with code RATE_LIMITED and a retryAfterMs hint -- the socket stays open.
See docs/overview.md for a deeper explanation of the algorithm and examples/server.ts for a runnable Node example.
Install
pnpm add @inkly/ratelimitQuick start
import { inkly, contract } from "@inkly/core";
import { installRateLimit } from "@inkly/ratelimit";
import { z } from "zod";
const api = contract({
actions: {
ping: { input: z.object({ message: z.string() }), output: z.object({ echo: z.string() }) },
},
});
const app = inkly(api);
// Register rate limiting before your action handlers.
installRateLimit(app, { capacity: 20, refillPerSec: 10 });
app.defineAction("ping", (_peer, input) => ({ echo: input.message }));Clients that exceed the limit receive:
{ "t": "err", "id": "...", "code": "RATE_LIMITED", "message": "Rate limit exceeded", "data": { "retryAfterMs": 350 } }API
installRateLimit(app, options) -- recommended
Registers a MessageAuthHook via app.use() and a close listener via app.on("close") that garbage-collects the peer's bucket on disconnect.
Returns a disposer () => void that unsubscribes the close listener (the hook itself stays active).
const dispose = installRateLimit(app, {
capacity: 20,
refillPerSec: 10,
message: "Too fast -- please slow down.",
onLimit: (peer, call) => metrics.increment("ratelimit.rejected", { peer: peer.id }),
});rateLimit(options) -- bare hook
Returns a MessageAuthHook for use with app.use(). The internal bucket Map is never cleaned up because this hook cannot observe disconnects. Use installRateLimit instead.
app.use(rateLimit({ capacity: 20, refillPerSec: 10 }));RateLimitError
Subclass of InklyError. Thrown by the hook when a peer's bucket is empty.
| Property | Value |
|---|---|
| code | "RATE_LIMITED" |
| status | 429 |
| data | { retryAfterMs: number } |
| retryAfterMs | milliseconds until enough tokens refill |
RateLimitOptions
| Option | Type | Default | Description |
|---|---|---|---|
| capacity | number | required | Max tokens (burst ceiling) |
| refillPerSec | number | required | Tokens added per second |
| cost | (call) => number | () => 1 | Token cost per call |
| key | (peer) => string | peer.id | Bucket key derivation |
| limits | Record<string, {capacity, refillPerSec}> | none | Per-call-name overrides |
| onLimit | (peer, call) => void | none | Rejection observer |
| message | string | "Rate limit exceeded" | Error message |
| now | () => number | Date.now | Injectable clock (tests) |
Token-bucket algorithm
On every call the hook runs:
elapsed = (now - last) / 1000
tokens = min(capacity, tokens + elapsed * refillPerSec)
last = now
if tokens < cost:
retryAfterMs = ceil((cost - tokens) / refillPerSec * 1000)
throw RateLimitError(retryAfterMs)
else:
tokens -= costA new bucket starts full (tokens = capacity). This gives each peer an initial burst up to capacity calls, then throttles sustained throughput to refillPerSec calls per second.
Caveat: bare rateLimit() leaks buckets
The bare rateLimit() hook has no access to disconnect events, so its internal bucket Map accumulates one entry per peer that ever connected and never shrinks. For long-running servers with many transient connections this is a memory leak.
installRateLimit() solves this by hooking app.on("close") to delete the peer's bucket immediately on disconnect. Always prefer installRateLimit.
Per-call-name limits
Use limits to enforce tighter quotas on expensive operations while keeping a generous global limit for cheap ones:
installRateLimit(app, {
capacity: 30,
refillPerSec: 15,
limits: {
heavyStream: { capacity: 2, refillPerSec: 0.5 },
},
});Calls to heavyStream are checked against their own per-name bucket; all other calls use the global bucket.
