svelte-adapter-uws-extensions 0.6.0-next.21 → 0.6.0-next.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.6.0-next.21",
3
+ "version": "0.6.0-next.22",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
@@ -11,6 +11,14 @@ export interface RedisRateLimitOptions {
11
11
  blockDuration?: number;
12
12
  /** Key extraction mode. @default 'ip' */
13
13
  keyBy?: 'ip' | 'connection' | ((ws: any) => string);
14
+ /**
15
+ * Optional per-connection tenant resolver. When set, the bucket key is scoped by
16
+ * the returned tenant id (joined to the key with a NUL, so it stays unambiguous even
17
+ * for IPv6 keys), so two tenants sharing an IP / connection / custom key get
18
+ * independent buckets. Return null/undefined for an unscoped connection; omit for a
19
+ * single-tenant deploy (byte-identical).
20
+ */
21
+ tenant?: (ws: any) => string | null | undefined;
14
22
  /** Prometheus metrics registry. */
15
23
  metrics?: MetricsRegistry;
16
24
  /** Circuit breaker instance. */
@@ -29,14 +37,14 @@ export interface ConsumeResult {
29
37
  export interface RedisRateLimiter {
30
38
  /** Attempt to consume tokens. */
31
39
  consume(ws: any, cost?: number): Promise<ConsumeResult>;
32
- /** Clear the bucket for a key. */
33
- reset(key: string): Promise<void>;
34
- /** Manually ban a key. */
35
- ban(key: string, duration?: number): Promise<void>;
36
- /** Remove a ban. */
37
- unban(key: string): Promise<void>;
38
- /** Reset all state. */
39
- clear(): Promise<void>;
40
+ /** Clear the bucket for a key (optionally scoped to a tenant). */
41
+ reset(key: string, tenant?: string | null): Promise<void>;
42
+ /** Manually ban a key (optionally scoped to a tenant). */
43
+ ban(key: string, duration?: number, tenant?: string | null): Promise<void>;
44
+ /** Remove a ban (optionally scoped to a tenant). */
45
+ unban(key: string, tenant?: string | null): Promise<void>;
46
+ /** Reset all state, or only one tenant's buckets when a tenant id is given. */
47
+ clear(tenant?: string | null): Promise<void>;
40
48
  }
41
49
 
42
50
  /**
@@ -42,6 +42,13 @@ return 1
42
42
  * @property {number} interval - Refill interval in milliseconds. Must be positive.
43
43
  * @property {number} [blockDuration=0] - Auto-ban duration in ms when exhausted. 0 = no ban.
44
44
  * @property {'ip' | 'connection' | ((ws: any) => string)} [keyBy='ip'] - Key extraction mode.
45
+ * @property {(ws: any) => (string | null | undefined)} [tenant] - Optional per-connection
46
+ * tenant resolver. When set, the bucket key is scoped by the returned tenant id, so two
47
+ * tenants sharing an IP / connection / custom key get independent buckets and a tenant's
48
+ * admin ops (`reset` / `ban` / `unban` / `clear`) touch only that tenant. Return
49
+ * null/undefined for an unscoped connection. Omit for a single-tenant deploy (byte-identical
50
+ * to before). The id should be a delimiter-safe slug - it is joined to the key with a NUL,
51
+ * so it stays unambiguous even when the key is an IPv6 address.
45
52
  */
46
53
 
47
54
  /**
@@ -54,10 +61,10 @@ return 1
54
61
  /**
55
62
  * @typedef {Object} RedisRateLimiter
56
63
  * @property {(ws: any, cost?: number) => Promise<ConsumeResult>} consume
57
- * @property {(key: string) => Promise<void>} reset
58
- * @property {(key: string, duration?: number) => Promise<void>} ban
59
- * @property {(key: string) => Promise<void>} unban
60
- * @property {() => Promise<void>} clear
64
+ * @property {(key: string, tenant?: string | null) => Promise<void>} reset
65
+ * @property {(key: string, duration?: number, tenant?: string | null) => Promise<void>} ban
66
+ * @property {(key: string, tenant?: string | null) => Promise<void>} unban
67
+ * @property {(tenant?: string | null) => Promise<void>} clear
61
68
  */
62
69
 
63
70
  /**
@@ -72,7 +79,7 @@ export function createRateLimit(client, options) {
72
79
  throw new Error('redis ratelimit: options object is required');
73
80
  }
74
81
 
75
- const { points, interval, blockDuration = 0, keyBy = 'ip' } = options;
82
+ const { points, interval, blockDuration = 0, keyBy = 'ip', tenant } = options;
76
83
 
77
84
  if (!Number.isInteger(points) || points <= 0) {
78
85
  throw new Error('redis ratelimit: points must be a positive integer');
@@ -86,6 +93,9 @@ export function createRateLimit(client, options) {
86
93
  if (keyBy !== 'ip' && keyBy !== 'connection' && typeof keyBy !== 'function') {
87
94
  throw new Error("redis ratelimit: keyBy must be 'ip', 'connection', or a function");
88
95
  }
96
+ if (tenant !== undefined && typeof tenant !== 'function') {
97
+ throw new Error('redis ratelimit: tenant must be a function (ws) => id | null');
98
+ }
89
99
 
90
100
  const redis = client.redis;
91
101
 
@@ -96,9 +106,15 @@ export function createRateLimit(client, options) {
96
106
 
97
107
  const b = options.breaker;
98
108
  const m = options.metrics;
99
- const mAllowed = m?.counter('ratelimit_allowed_total', 'Requests allowed');
100
- const mDenied = m?.counter('ratelimit_denied_total', 'Requests denied');
101
- const mBans = m?.counter('ratelimit_bans_total', 'Bans applied');
109
+ // When a tenant resolver is set, label the rate-limit counters by tenant so an
110
+ // operator can see per-tenant allow/deny/ban rates. Opt-in (no resolver -> no
111
+ // label, byte-identical series); the label is bounded by the metric's default
112
+ // max-series cardinality cap, so a tenant burst cannot blow up the registry.
113
+ const labelTenants = typeof tenant === 'function';
114
+ const tlabels = labelTenants ? ['tenant_id'] : undefined;
115
+ const mAllowed = m?.counter('ratelimit_allowed_total', 'Requests allowed', tlabels);
116
+ const mDenied = m?.counter('ratelimit_denied_total', 'Requests denied', tlabels);
117
+ const mBans = m?.counter('ratelimit_bans_total', 'Bans applied', tlabels);
102
118
 
103
119
  // Per-connection keying uses a WeakMap to avoid leaks
104
120
  const wsKeys = new WeakMap();
@@ -129,8 +145,18 @@ export function createRateLimit(client, options) {
129
145
  return 'unknown';
130
146
  }
131
147
 
132
- function bucketKey(key) {
133
- return client.key(SCRIPT_VERSION + ':ratelimit:' + key);
148
+ // The tenant segment (when a `tenant` resolver is set) is FIRST and NUL-delimited,
149
+ // so a validated id stays unambiguous even when the key is an IPv6 address (colons).
150
+ // Null tenant -> no segment, byte-identical to the single-tenant key space. The id is
151
+ // rejected if it contains the NUL delimiter (the one char that would let two distinct
152
+ // tenants collide on one bucket); this is the injection-safety the realtime tier
153
+ // validates at its own boundary, enforced here too so the property does not silently
154
+ // depend on the caller's resolver. The check short-circuits on the null (default) path.
155
+ function bucketKey(key, tenantId) {
156
+ if (tenantId && tenantId.indexOf('\0') !== -1) {
157
+ throw new Error('redis ratelimit: tenant id must not contain a NUL byte (it is the bucket-key delimiter)');
158
+ }
159
+ return client.key(SCRIPT_VERSION + ':ratelimit:' + (tenantId ? tenantId + '\0' : '') + key);
134
160
  }
135
161
 
136
162
  return {
@@ -139,16 +165,18 @@ export function createRateLimit(client, options) {
139
165
  throw new Error('redis ratelimit: cost must be a positive integer');
140
166
  }
141
167
  const key = resolveKey(ws);
168
+ const tenantId = tenant ? tenant(ws) : null;
142
169
 
143
170
  const result = await withBreaker(b, () =>
144
- redis.eval(CONSUME_SCRIPT, 1, bucketKey(key), points, interval, cost, blockDuration)
171
+ redis.eval(CONSUME_SCRIPT, 1, bucketKey(key, tenantId), points, interval, cost, blockDuration)
145
172
  );
146
173
 
147
174
  const allowed = result[0] === 1;
175
+ const labels = labelTenants ? { tenant_id: tenantId || '' } : undefined;
148
176
  if (allowed) {
149
- mAllowed?.inc();
177
+ mAllowed?.inc(labels);
150
178
  } else {
151
- mDenied?.inc();
179
+ mDenied?.inc(labels);
152
180
  }
153
181
 
154
182
  return {
@@ -158,24 +186,28 @@ export function createRateLimit(client, options) {
158
186
  };
159
187
  },
160
188
 
161
- async reset(key) {
162
- await withBreaker(b, () => redis.del(bucketKey(key)));
189
+ async reset(key, tenantId) {
190
+ await withBreaker(b, () => redis.del(bucketKey(key, tenantId)));
163
191
  },
164
192
 
165
- async ban(key, duration) {
193
+ async ban(key, duration, tenantId) {
166
194
  const dur = duration ?? (blockDuration || 60000);
167
195
  if (dur <= 0) throw new Error('redis ratelimit: ban duration must be positive');
168
- const bk = bucketKey(key);
196
+ const bk = bucketKey(key, tenantId);
169
197
  await withBreaker(b, () => redis.eval(BAN_SCRIPT, 1, bk, dur, points, interval));
170
- mBans?.inc();
198
+ mBans?.inc(labelTenants ? { tenant_id: tenantId || '' } : undefined);
171
199
  },
172
200
 
173
- async unban(key) {
174
- await withBreaker(b, () => redis.hset(bucketKey(key), 'bannedUntil', 0));
201
+ async unban(key, tenantId) {
202
+ await withBreaker(b, () => redis.hset(bucketKey(key, tenantId), 'bannedUntil', 0));
175
203
  },
176
204
 
177
- async clear() {
178
- await withBreaker(b, () => scanAndUnlink(redis, client.key(SCRIPT_VERSION + ':ratelimit:*')));
205
+ // No tenant -> clears the whole key space (every tenant's buckets too, since the
206
+ // glob `*` spans the NUL-delimited tenant segments). Pass a tenant id to clear
207
+ // only that tenant's buckets.
208
+ async clear(tenantId) {
209
+ const suffix = tenantId ? tenantId + '\0*' : '*';
210
+ await withBreaker(b, () => scanAndUnlink(redis, client.key(SCRIPT_VERSION + ':ratelimit:' + suffix)));
179
211
  }
180
212
  };
181
213
  }
@@ -12,20 +12,29 @@ export interface CircuitBreakerOptions {
12
12
  }
13
13
 
14
14
  export interface CircuitBreaker {
15
- /** Current state. */
15
+ /**
16
+ * State is partitioned by an optional string `key`, so one key (e.g. a tenant) can
17
+ * break without tripping the others. The default key `''` is the single global
18
+ * breaker - every method is byte-identical for callers that pass no key.
19
+ */
20
+ /** State of the default (`''`) key. */
16
21
  readonly state: 'healthy' | 'broken' | 'probing';
17
- /** True only when state is healthy. */
22
+ /** True only when the default key's state is healthy. */
18
23
  readonly isHealthy: boolean;
19
- /** Current consecutive failure count. */
24
+ /** Default key's consecutive failure count. */
20
25
  readonly failures: number;
21
- /** Throws CircuitBrokenError if the circuit is broken. */
22
- guard(): void;
23
- /** Record a successful operation. May transition to healthy. */
24
- success(): void;
25
- /** Record a failed operation. May transition to broken. */
26
- failure(err?: any): void;
27
- /** Force back to healthy state. */
28
- reset(): void;
26
+ /** State for a given key (default `''`). */
27
+ stateOf(key?: string): 'healthy' | 'broken' | 'probing';
28
+ /** Failure count for a given key (default `''`). */
29
+ failuresOf(key?: string): number;
30
+ /** Throws CircuitBrokenError if the circuit for `key` (default `''`) is broken. */
31
+ guard(key?: string): void;
32
+ /** Record a successful operation for `key`. May transition to healthy. */
33
+ success(key?: string): void;
34
+ /** Record a failed operation for `key`. May transition to broken. */
35
+ failure(err?: any, key?: string): void;
36
+ /** Force `key` (default `''`) back to healthy state. */
37
+ reset(key?: string): void;
29
38
  /**
30
39
  * Register a state-transition listener. Multiple subscribers are
31
40
  * supported and the constructor-time `onStateChange` callback (if
@@ -40,9 +49,10 @@ export interface CircuitBreaker {
40
49
 
41
50
  /**
42
51
  * Run an async operation through a breaker. Guards before, records
43
- * success/failure after. Pass null/undefined breaker to skip.
52
+ * success/failure after. Pass null/undefined breaker to skip. The optional `key`
53
+ * partitions the breaker state (default `''` = the global breaker).
44
54
  */
45
- export function withBreaker<T>(breaker: CircuitBreaker | null | undefined, fn: () => Promise<T>): Promise<T>;
55
+ export function withBreaker<T>(breaker: CircuitBreaker | null | undefined, fn: () => Promise<T>, key?: string): Promise<T>;
46
56
 
47
57
  /**
48
58
  * Create a circuit breaker.
@@ -14,7 +14,7 @@
14
14
  * @module svelte-adapter-uws-extensions/breaker
15
15
  */
16
16
 
17
- import { MAX_BREAKER_LISTENERS } from './caps.js';
17
+ import { MAX_BREAKER_LISTENERS, MAX_BREAKER_KEYS } from './caps.js';
18
18
  import { setTimer, clearTimer } from './runtime.js';
19
19
 
20
20
  export class CircuitBrokenError extends Error {
@@ -32,16 +32,24 @@ export class CircuitBrokenError extends Error {
32
32
  */
33
33
 
34
34
  /**
35
+ * Per-KEY circuit breaker. State is partitioned by an optional string key, so one
36
+ * key (e.g. a tenant) can break without tripping the others. The default key `''`
37
+ * is the single global breaker - every method is byte-identical for callers that
38
+ * pass no key, so existing shared-infra call sites are unchanged. A caller
39
+ * protecting a tenant-specific resource passes the tenant id to isolate its state.
40
+ *
35
41
  * @typedef {Object} CircuitBreaker
36
- * @property {'healthy' | 'broken' | 'probing'} state
37
- * @property {boolean} isHealthy - True only when state === 'healthy'
38
- * @property {number} failures - Current consecutive failure count
39
- * @property {() => void} guard - Throws CircuitBrokenError if broken
40
- * @property {() => void} success - Record a successful operation
41
- * @property {(err?: any) => void} failure - Record a failed operation
42
- * @property {() => void} reset - Force back to healthy
42
+ * @property {'healthy' | 'broken' | 'probing'} state - State of the default (`''`) key
43
+ * @property {boolean} isHealthy - True only when the default key's state === 'healthy'
44
+ * @property {number} failures - Default key's consecutive failure count
45
+ * @property {(key?: string) => ('healthy' | 'broken' | 'probing')} stateOf - State for a given key
46
+ * @property {(key?: string) => number} failuresOf - Failure count for a given key
47
+ * @property {(key?: string) => void} guard - Throws CircuitBrokenError if that key is broken
48
+ * @property {(key?: string) => void} success - Record a successful operation for a key
49
+ * @property {(err?: any, key?: string) => void} failure - Record a failed operation for a key
50
+ * @property {(key?: string) => void} reset - Force a key back to healthy
43
51
  * @property {(handler: (from: string, to: string) => void) => () => void} subscribe - Register a state-transition listener; returns an unsubscribe function
44
- * @property {() => void} destroy - Clear internal timers
52
+ * @property {() => void} destroy - Clear internal timers (all keys)
45
53
  */
46
54
 
47
55
  /**
@@ -63,17 +71,20 @@ export class CircuitBrokenError extends Error {
63
71
  */
64
72
  /**
65
73
  * Run an async operation through a breaker. Guards before, records
66
- * success/failure after. Pass null/undefined breaker to skip.
74
+ * success/failure after. Pass null/undefined breaker to skip. The optional `key`
75
+ * partitions the breaker state (default `''` = the global breaker) - shared-infra
76
+ * call sites pass no key; a caller protecting a tenant-specific resource passes the
77
+ * tenant id so its failures cannot trip another tenant's breaker.
67
78
  */
68
- export async function withBreaker(b, fn) {
79
+ export async function withBreaker(b, fn, key) {
69
80
  if (!b) return fn();
70
- b.guard();
81
+ b.guard(key);
71
82
  try {
72
83
  const result = await fn();
73
- b.success();
84
+ b.success(key);
74
85
  return result;
75
86
  } catch (err) {
76
- b.failure(err);
87
+ b.failure(err, key);
77
88
  throw err;
78
89
  }
79
90
  }
@@ -90,75 +101,105 @@ export function createCircuitBreaker(options = {}) {
90
101
  throw new Error('circuit breaker: resetTimeout must be a non-negative number');
91
102
  }
92
103
 
93
- let state = 'healthy';
94
- let failures = 0;
95
- let probeAllowed = false;
96
- let resetTimer = null;
104
+ // Per-key state. The default key '' is the single global breaker - every existing
105
+ // (no-key) caller hits exactly this one slot, so behavior is byte-identical.
106
+ /** @type {Map<string, { state: string, failures: number, probeAllowed: boolean, resetTimer: any }>} */
107
+ const states = new Map();
108
+ function stateFor(key) {
109
+ const k = key || '';
110
+ let s = states.get(k);
111
+ if (!s) {
112
+ // Backstop against unbounded key growth: at the cap, evict the oldest
113
+ // non-default keyed state (the '' global key is never evicted). A healthy
114
+ // evicted key simply recreates on next access; an evicted broken key is
115
+ // treated as healthy until it re-breaks - bounded graceful degradation,
116
+ // the same trade-off as the rate-limiter's maxBuckets.
117
+ if (states.size >= MAX_BREAKER_KEYS) {
118
+ for (const existing of states.keys()) {
119
+ if (existing !== '') {
120
+ const old = states.get(existing);
121
+ if (old && old.resetTimer) clearTimer(old.resetTimer);
122
+ states.delete(existing);
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ s = { state: 'healthy', failures: 0, probeAllowed: false, resetTimer: null };
128
+ states.set(k, s);
129
+ }
130
+ return s;
131
+ }
97
132
 
98
133
  const listeners = new Set();
99
134
  if (onStateChange) listeners.add(onStateChange);
100
135
 
101
- function transition(to) {
102
- const from = state;
136
+ function transition(s, to) {
137
+ const from = s.state;
103
138
  if (from === to) return;
104
- state = to;
139
+ s.state = to;
105
140
  for (const listener of listeners) {
106
141
  try { listener(from, to); } catch { /* don't let one listener break the others */ }
107
142
  }
108
143
  }
109
144
 
110
- function scheduleProbe() {
111
- clearTimer(resetTimer);
112
- resetTimer = setTimer(() => {
113
- resetTimer = null;
114
- probeAllowed = true;
115
- transition('probing');
145
+ function scheduleProbe(s) {
146
+ clearTimer(s.resetTimer);
147
+ s.resetTimer = setTimer(() => {
148
+ s.resetTimer = null;
149
+ s.probeAllowed = true;
150
+ transition(s, 'probing');
116
151
  }, resetTimeout);
117
- if (resetTimer.unref) resetTimer.unref();
152
+ if (s.resetTimer.unref) s.resetTimer.unref();
118
153
  }
119
154
 
120
155
  return {
121
- get state() { return state; },
122
- get isHealthy() { return state === 'healthy'; },
123
- get failures() { return failures; },
124
-
125
- guard() {
126
- if (state === 'healthy') return;
127
- if (state === 'probing' && probeAllowed) {
128
- probeAllowed = false;
156
+ get state() { return stateFor('').state; },
157
+ get isHealthy() { return stateFor('').state === 'healthy'; },
158
+ get failures() { return stateFor('').failures; },
159
+ stateOf(key) { return stateFor(key).state; },
160
+ failuresOf(key) { return stateFor(key).failures; },
161
+
162
+ guard(key) {
163
+ const s = stateFor(key);
164
+ if (s.state === 'healthy') return;
165
+ if (s.state === 'probing' && s.probeAllowed) {
166
+ s.probeAllowed = false;
129
167
  return;
130
168
  }
131
169
  throw new CircuitBrokenError();
132
170
  },
133
171
 
134
- success() {
135
- if (state === 'probing') {
136
- clearTimer(resetTimer);
137
- resetTimer = null;
138
- failures = 0;
139
- transition('healthy');
140
- } else if (state === 'healthy') {
141
- failures = 0;
172
+ success(key) {
173
+ const s = stateFor(key);
174
+ if (s.state === 'probing') {
175
+ clearTimer(s.resetTimer);
176
+ s.resetTimer = null;
177
+ s.failures = 0;
178
+ transition(s, 'healthy');
179
+ } else if (s.state === 'healthy') {
180
+ s.failures = 0;
142
181
  }
143
182
  },
144
183
 
145
- failure() {
146
- if (failures < failureThreshold) failures++;
147
- if (state === 'probing') {
148
- transition('broken');
149
- scheduleProbe();
150
- } else if (state === 'healthy' && failures >= failureThreshold) {
151
- transition('broken');
152
- scheduleProbe();
184
+ failure(err, key) {
185
+ const s = stateFor(key);
186
+ if (s.failures < failureThreshold) s.failures++;
187
+ if (s.state === 'probing') {
188
+ transition(s, 'broken');
189
+ scheduleProbe(s);
190
+ } else if (s.state === 'healthy' && s.failures >= failureThreshold) {
191
+ transition(s, 'broken');
192
+ scheduleProbe(s);
153
193
  }
154
194
  },
155
195
 
156
- reset() {
157
- clearTimer(resetTimer);
158
- resetTimer = null;
159
- failures = 0;
160
- probeAllowed = false;
161
- transition('healthy');
196
+ reset(key) {
197
+ const s = stateFor(key);
198
+ clearTimer(s.resetTimer);
199
+ s.resetTimer = null;
200
+ s.failures = 0;
201
+ s.probeAllowed = false;
202
+ transition(s, 'healthy');
162
203
  },
163
204
 
164
205
  subscribe(handler) {
@@ -176,8 +217,10 @@ export function createCircuitBreaker(options = {}) {
176
217
  },
177
218
 
178
219
  destroy() {
179
- clearTimer(resetTimer);
180
- resetTimer = null;
220
+ for (const s of states.values()) {
221
+ clearTimer(s.resetTimer);
222
+ s.resetTimer = null;
223
+ }
181
224
  }
182
225
  };
183
226
  }
@@ -100,6 +100,14 @@ export const MAX_TASK_HANDLERS = 10_000;
100
100
  /** `breaker.subscribe(handler)` listeners on a single breaker. */
101
101
  export const MAX_BREAKER_LISTENERS = 10_000;
102
102
 
103
+ /**
104
+ * Distinct per-key state slots on a single breaker (the default `''` global key
105
+ * plus one per caller-supplied key, e.g. a tenant). A backstop against unbounded
106
+ * growth if a caller ever keys the breaker by an untrusted/unbounded value; the
107
+ * intended key sources (tenant ids) are bounded well below this.
108
+ */
109
+ export const MAX_BREAKER_KEYS = 10_000;
110
+
103
111
  /**
104
112
  * Defense-in-depth cap on the idempotency-store key after framework-level
105
113
  * namespacing. Matches the worst-case `rpc:<path>:<256-char-user-key>` /