svelte-adapter-uws-extensions 0.1.2

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.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Redis pub/sub bus for svelte-adapter-uws.
3
+ *
4
+ * Distributes WebSocket publishes across multiple server instances via Redis.
5
+ * Each instance publishes to Redis AND locally. Incoming Redis messages are
6
+ * forwarded to the local platform.publish() with a flag to prevent re-publishing
7
+ * back to Redis (relay loop prevention).
8
+ *
9
+ * @module svelte-adapter-uws-extensions/redis/pubsub
10
+ */
11
+
12
+ import { randomBytes } from 'node:crypto';
13
+
14
+ /**
15
+ * @typedef {Object} PubSubBusOptions
16
+ * @property {string} [channel='uws:pubsub'] - Redis channel name for pub/sub messages
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} PubSubBus
21
+ * @property {(platform: import('svelte-adapter-uws').Platform) => import('svelte-adapter-uws').Platform} wrap -
22
+ * Returns a new Platform whose publish() sends to Redis + local.
23
+ * Use this wrapped platform everywhere you call publish().
24
+ * @property {(platform: import('svelte-adapter-uws').Platform) => Promise<void>} activate -
25
+ * Start the Redis subscriber. Incoming messages are forwarded to the
26
+ * original platform.publish(). Call this once at startup (e.g. in your open hook).
27
+ * @property {() => Promise<void>} deactivate -
28
+ * Stop the Redis subscriber and clean up.
29
+ */
30
+
31
+ /**
32
+ * Create a Redis-backed pub/sub bus.
33
+ *
34
+ * @param {import('./index.js').RedisClient} client - Redis client from createRedisClient
35
+ * @param {PubSubBusOptions} [options]
36
+ * @returns {PubSubBus}
37
+ *
38
+ * @example
39
+ * ```js
40
+ * import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
41
+ * import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
42
+ *
43
+ * const redis = createRedisClient({ url: 'redis://localhost:6379' });
44
+ * const bus = createPubSubBus(redis);
45
+ *
46
+ * // In your open hook:
47
+ * export function open(ws, { platform }) {
48
+ * bus.activate(platform); // idempotent, only subscribes once
49
+ * }
50
+ *
51
+ * // Use the wrapped platform for publishing:
52
+ * const distributed = bus.wrap(platform);
53
+ * distributed.publish('chat', 'message', { text: 'hello' });
54
+ * ```
55
+ */
56
+ export function createPubSubBus(client, options = {}) {
57
+ const channel = options.channel || 'uws:pubsub';
58
+ const instanceId = randomBytes(8).toString('hex');
59
+
60
+ /** @type {import('ioredis').Redis | null} */
61
+ let subscriber = null;
62
+
63
+ /** @type {boolean} */
64
+ let active = false;
65
+
66
+ /** @type {import('svelte-adapter-uws').Platform | null} */
67
+ let activePlatform = null;
68
+
69
+ return {
70
+ wrap(platform) {
71
+ const wrapped = {
72
+ publish(topic, event, data, options) {
73
+ // Publish locally, forwarding options as-is
74
+ const result = platform.publish(topic, event, data, options);
75
+
76
+ // Only relay to Redis if the caller did not suppress relay.
77
+ // When relay is explicitly false the message is local-only
78
+ // (e.g. it already came from Redis on another instance).
79
+ if (!options || options.relay !== false) {
80
+ const msg = JSON.stringify({ instanceId, topic, event, data });
81
+ client.redis.publish(channel, msg).catch(() => {
82
+ // Fire-and-forget: ioredis auto-reconnects.
83
+ // Swallowing here prevents unhandled rejections on transient disconnects.
84
+ });
85
+ }
86
+
87
+ return result;
88
+ },
89
+ send: platform.send.bind(platform),
90
+ sendTo: platform.sendTo.bind(platform),
91
+ get connections() { return platform.connections; },
92
+ subscribers: platform.subscribers.bind(platform),
93
+ topic(t) {
94
+ return {
95
+ publish(event, data) { wrapped.publish(t, event, data); },
96
+ created(data) { wrapped.publish(t, 'created', data); },
97
+ updated(data) { wrapped.publish(t, 'updated', data); },
98
+ deleted(data) { wrapped.publish(t, 'deleted', data); },
99
+ set(value) { wrapped.publish(t, 'set', value); },
100
+ increment(amount) { wrapped.publish(t, 'increment', amount); },
101
+ decrement(amount) { wrapped.publish(t, 'decrement', amount); }
102
+ };
103
+ }
104
+ };
105
+ return wrapped;
106
+ },
107
+
108
+ async activate(platform) {
109
+ // Always update the platform reference so remote messages
110
+ // are forwarded through the latest platform, even if a
111
+ // previous activate() already started the subscriber.
112
+ activePlatform = platform;
113
+ if (active) return;
114
+
115
+ subscriber = client.duplicate({ enableReadyCheck: false });
116
+
117
+ subscriber.on('message', (ch, message) => {
118
+ if (ch !== channel) return;
119
+ try {
120
+ const parsed = JSON.parse(message);
121
+ // Skip messages from this instance (echo suppression)
122
+ if (parsed.instanceId === instanceId) return;
123
+ // Forward to local platform only -- relay: false prevents the
124
+ // adapter from IPC-relaying to sibling workers, since each
125
+ // worker has its own Redis subscriber already receiving this.
126
+ activePlatform.publish(parsed.topic, parsed.event, parsed.data, { relay: false });
127
+ } catch {
128
+ // Malformed message, skip
129
+ }
130
+ });
131
+
132
+ try {
133
+ await subscriber.subscribe(channel);
134
+ active = true;
135
+ } catch (err) {
136
+ // Clean up so the next activate() call can retry
137
+ activePlatform = null;
138
+ subscriber.quit().catch(() => subscriber.disconnect());
139
+ subscriber = null;
140
+ throw err;
141
+ }
142
+ },
143
+
144
+ async deactivate() {
145
+ if (!active || !subscriber) return;
146
+ active = false;
147
+ activePlatform = null;
148
+ await subscriber.unsubscribe(channel).catch(() => {});
149
+ await subscriber.quit().catch(() => subscriber.disconnect());
150
+ subscriber = null;
151
+ }
152
+ };
153
+ }
@@ -0,0 +1,39 @@
1
+ import type { RedisClient } from './index.js';
2
+
3
+ export interface RedisRateLimitOptions {
4
+ /** Tokens available per interval. Must be a positive integer. */
5
+ points: number;
6
+ /** Refill interval in milliseconds. Must be positive. */
7
+ interval: number;
8
+ /** Auto-ban duration in ms when exhausted. 0 = no ban. @default 0 */
9
+ blockDuration?: number;
10
+ /** Key extraction mode. @default 'ip' */
11
+ keyBy?: 'ip' | 'connection' | ((ws: any) => string);
12
+ }
13
+
14
+ export interface ConsumeResult {
15
+ /** Whether the request was permitted. */
16
+ allowed: boolean;
17
+ /** Tokens left in the bucket (0 if banned or exhausted). */
18
+ remaining: number;
19
+ /** Milliseconds until the bucket refills or the ban expires. */
20
+ resetMs: number;
21
+ }
22
+
23
+ export interface RedisRateLimiter {
24
+ /** Attempt to consume tokens. */
25
+ consume(ws: any, cost?: number): Promise<ConsumeResult>;
26
+ /** Clear the bucket for a key. */
27
+ reset(key: string): Promise<void>;
28
+ /** Manually ban a key. */
29
+ ban(key: string, duration?: number): Promise<void>;
30
+ /** Remove a ban. */
31
+ unban(key: string): Promise<void>;
32
+ /** Reset all state. */
33
+ clear(): Promise<void>;
34
+ }
35
+
36
+ /**
37
+ * Create a Redis-backed rate limiter using atomic Lua scripts.
38
+ */
39
+ export function createRateLimit(client: RedisClient, options: RedisRateLimitOptions): RedisRateLimiter;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Redis-backed rate limiter for svelte-adapter-uws.
3
+ *
4
+ * Same API as the core createRateLimit plugin, but stores bucket state
5
+ * in Redis so rate limits are enforced across all server instances.
6
+ *
7
+ * Uses a Lua script for atomic token consumption to avoid race conditions.
8
+ * The Lua script runs entirely on the Redis server, so there is exactly
9
+ * one roundtrip per consume() call.
10
+ *
11
+ * @module svelte-adapter-uws-extensions/redis/ratelimit
12
+ */
13
+
14
+ /**
15
+ * Lua script for atomic token bucket consumption.
16
+ *
17
+ * KEYS[1] = bucket key (hash with fields: points, resetAt, bannedUntil)
18
+ * ARGV[1] = max points
19
+ * ARGV[2] = interval (ms)
20
+ * ARGV[3] = cost
21
+ * ARGV[4] = now (ms)
22
+ * ARGV[5] = blockDuration (ms)
23
+ *
24
+ * Returns: [allowed (0/1), remaining, resetMs]
25
+ */
26
+ const CONSUME_SCRIPT = `
27
+ local key = KEYS[1]
28
+ local maxPoints = tonumber(ARGV[1])
29
+ local interval = tonumber(ARGV[2])
30
+ local cost = tonumber(ARGV[3])
31
+ local now = tonumber(ARGV[4])
32
+ local blockDuration = tonumber(ARGV[5])
33
+
34
+ local points = tonumber(redis.call('hget', key, 'points'))
35
+ local resetAt = tonumber(redis.call('hget', key, 'resetAt'))
36
+ local bannedUntil = tonumber(redis.call('hget', key, 'bannedUntil'))
37
+
38
+ -- Initialize if missing
39
+ if points == nil then
40
+ points = maxPoints
41
+ resetAt = now + interval
42
+ bannedUntil = 0
43
+ end
44
+
45
+ -- Check ban
46
+ if bannedUntil > now then
47
+ return {0, 0, bannedUntil - now}
48
+ end
49
+
50
+ -- Refill if interval elapsed
51
+ if resetAt <= now then
52
+ points = maxPoints
53
+ resetAt = now + interval
54
+ end
55
+
56
+ -- Try to consume
57
+ if points >= cost then
58
+ points = points - cost
59
+ redis.call('hmset', key, 'points', points, 'resetAt', resetAt, 'bannedUntil', bannedUntil)
60
+ -- Set TTL to avoid stale keys: interval + blockDuration + buffer
61
+ local ttlMs = interval + blockDuration + 60000
62
+ redis.call('pexpire', key, ttlMs)
63
+ return {1, points, resetAt - now}
64
+ end
65
+
66
+ -- Exhausted
67
+ if blockDuration > 0 then
68
+ bannedUntil = now + blockDuration
69
+ redis.call('hmset', key, 'points', points, 'resetAt', resetAt, 'bannedUntil', bannedUntil)
70
+ local ttlMs = blockDuration + 60000
71
+ redis.call('pexpire', key, ttlMs)
72
+ return {0, 0, blockDuration}
73
+ end
74
+
75
+ redis.call('hmset', key, 'points', points, 'resetAt', resetAt, 'bannedUntil', bannedUntil)
76
+ local ttlMs = interval + 60000
77
+ redis.call('pexpire', key, ttlMs)
78
+ return {0, math.max(0, points), resetAt - now}
79
+ `;
80
+
81
+ /**
82
+ * @typedef {Object} RedisRateLimitOptions
83
+ * @property {number} points - Tokens available per interval. Must be a positive integer.
84
+ * @property {number} interval - Refill interval in milliseconds. Must be positive.
85
+ * @property {number} [blockDuration=0] - Auto-ban duration in ms when exhausted. 0 = no ban.
86
+ * @property {'ip' | 'connection' | ((ws: any) => string)} [keyBy='ip'] - Key extraction mode.
87
+ */
88
+
89
+ /**
90
+ * @typedef {Object} ConsumeResult
91
+ * @property {boolean} allowed
92
+ * @property {number} remaining
93
+ * @property {number} resetMs
94
+ */
95
+
96
+ /**
97
+ * @typedef {Object} RedisRateLimiter
98
+ * @property {(ws: any, cost?: number) => Promise<ConsumeResult>} consume
99
+ * @property {(key: string) => Promise<void>} reset
100
+ * @property {(key: string, duration?: number) => Promise<void>} ban
101
+ * @property {(key: string) => Promise<void>} unban
102
+ * @property {() => Promise<void>} clear
103
+ */
104
+
105
+ /**
106
+ * Create a Redis-backed rate limiter.
107
+ *
108
+ * @param {import('./index.js').RedisClient} client
109
+ * @param {RedisRateLimitOptions} options
110
+ * @returns {RedisRateLimiter}
111
+ */
112
+ export function createRateLimit(client, options) {
113
+ if (!options || typeof options !== 'object') {
114
+ throw new Error('redis ratelimit: options object is required');
115
+ }
116
+
117
+ const { points, interval, blockDuration = 0, keyBy = 'ip' } = options;
118
+
119
+ if (!Number.isInteger(points) || points <= 0) {
120
+ throw new Error('redis ratelimit: points must be a positive integer');
121
+ }
122
+ if (typeof interval !== 'number' || !Number.isFinite(interval) || interval <= 0) {
123
+ throw new Error('redis ratelimit: interval must be a positive number');
124
+ }
125
+ if (typeof blockDuration !== 'number' || !Number.isFinite(blockDuration) || blockDuration < 0) {
126
+ throw new Error('redis ratelimit: blockDuration must be a non-negative number');
127
+ }
128
+ if (keyBy !== 'ip' && keyBy !== 'connection' && typeof keyBy !== 'function') {
129
+ throw new Error("redis ratelimit: keyBy must be 'ip', 'connection', or a function");
130
+ }
131
+
132
+ const redis = client.redis;
133
+
134
+ // Per-connection keying uses a WeakMap to avoid leaks
135
+ const wsKeys = new WeakMap();
136
+ let connCounter = 0;
137
+
138
+ function resolveKey(ws) {
139
+ if (typeof keyBy === 'function') return keyBy(ws);
140
+ if (keyBy === 'connection') {
141
+ let k = wsKeys.get(ws);
142
+ if (!k) {
143
+ k = '__conn:' + (++connCounter);
144
+ wsKeys.set(ws, k);
145
+ }
146
+ return k;
147
+ }
148
+ const ud = typeof ws.getUserData === 'function' ? ws.getUserData() : null;
149
+ if (ud) {
150
+ return String(ud.remoteAddress || ud.ip || ud.address || 'unknown');
151
+ }
152
+ return 'unknown';
153
+ }
154
+
155
+ function bucketKey(key) {
156
+ return client.key('ratelimit:' + key);
157
+ }
158
+
159
+ return {
160
+ async consume(ws, cost = 1) {
161
+ if (typeof cost !== 'number' || !Number.isInteger(cost) || cost < 1) {
162
+ throw new Error('redis ratelimit: cost must be a positive integer');
163
+ }
164
+ const key = resolveKey(ws);
165
+ const now = Date.now();
166
+
167
+ const result = await redis.eval(
168
+ CONSUME_SCRIPT,
169
+ 1,
170
+ bucketKey(key),
171
+ points,
172
+ interval,
173
+ cost,
174
+ now,
175
+ blockDuration
176
+ );
177
+
178
+ return {
179
+ allowed: result[0] === 1,
180
+ remaining: result[1],
181
+ resetMs: result[2]
182
+ };
183
+ },
184
+
185
+ async reset(key) {
186
+ await redis.del(bucketKey(key));
187
+ },
188
+
189
+ async ban(key, duration) {
190
+ const dur = duration ?? (blockDuration || 60000);
191
+ const now = Date.now();
192
+ const bk = bucketKey(key);
193
+ // Preserve existing points (if any) so unban restores the bucket
194
+ const existingPoints = await redis.hget(bk, 'points');
195
+ const existingResetAt = await redis.hget(bk, 'resetAt');
196
+ await redis.hmset(
197
+ bk,
198
+ 'points', existingPoints ?? points,
199
+ 'resetAt', existingResetAt ?? (now + interval),
200
+ 'bannedUntil', now + dur
201
+ );
202
+ await redis.pexpire(bk, dur + 60000);
203
+ },
204
+
205
+ async unban(key) {
206
+ const bk = bucketKey(key);
207
+ await redis.hset(bk, 'bannedUntil', 0);
208
+ },
209
+
210
+ async clear() {
211
+ const pattern = client.key('ratelimit:*');
212
+ let cursor = '0';
213
+ do {
214
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
215
+ cursor = nextCursor;
216
+ if (keys.length > 0) {
217
+ await redis.del(...keys);
218
+ }
219
+ } while (cursor !== '0');
220
+ connCounter = 0;
221
+ }
222
+ };
223
+ }
@@ -0,0 +1,47 @@
1
+ import type { Platform } from 'svelte-adapter-uws';
2
+ import type { RedisClient } from './index.js';
3
+
4
+ export interface RedisReplayOptions {
5
+ /** Max messages per topic. @default 1000 */
6
+ size?: number;
7
+ /** TTL in seconds for replay keys (0 = no expiry). @default 0 */
8
+ ttl?: number;
9
+ }
10
+
11
+ export interface BufferedMessage {
12
+ seq: number;
13
+ topic: string;
14
+ event: string;
15
+ data: unknown;
16
+ }
17
+
18
+ export interface RedisReplayBuffer {
19
+ /**
20
+ * Publish a message through the buffer. Stores it in Redis with a
21
+ * sequence number, then calls platform.publish() as normal.
22
+ */
23
+ publish(platform: Platform, topic: string, event: string, data?: unknown): Promise<boolean>;
24
+
25
+ /** Get the current sequence number for a topic. Returns 0 if unknown. */
26
+ seq(topic: string): Promise<number>;
27
+
28
+ /** Get all buffered messages after a given sequence number. */
29
+ since(topic: string, since: number): Promise<BufferedMessage[]>;
30
+
31
+ /**
32
+ * Send buffered messages to a single connection. Sends each missed
33
+ * message on `__replay:{topic}`, then an end marker.
34
+ */
35
+ replay(ws: any, topic: string, sinceSeq: number, platform: Platform): Promise<void>;
36
+
37
+ /** Clear all replay buffers. */
38
+ clear(): Promise<void>;
39
+
40
+ /** Clear the buffer for a single topic. */
41
+ clearTopic(topic: string): Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Create a Redis-backed replay buffer.
46
+ */
47
+ export function createReplay(client: RedisClient, options?: RedisReplayOptions): RedisReplayBuffer;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Redis-backed replay buffer for svelte-adapter-uws.
3
+ *
4
+ * Same API as the core createReplay plugin, but stores messages in Redis
5
+ * sorted sets so they survive restarts and are shared across instances.
6
+ *
7
+ * Storage layout per topic:
8
+ * - Key `{prefix}replay:seq:{topic}` - INCR counter for sequence numbers
9
+ * - Key `{prefix}replay:buf:{topic}` - sorted set (score = seq, member = JSON payload)
10
+ *
11
+ * @module svelte-adapter-uws-extensions/redis/replay
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} RedisReplayOptions
16
+ * @property {number} [size=1000] - Max messages per topic
17
+ * @property {number} [ttl=0] - TTL in seconds for replay keys (0 = no expiry)
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} RedisReplayBuffer
22
+ * @property {(platform: import('svelte-adapter-uws').Platform, topic: string, event: string, data?: unknown) => Promise<boolean>} publish
23
+ * @property {(topic: string) => Promise<number>} seq
24
+ * @property {(topic: string, since: number) => Promise<Array<{seq: number, topic: string, event: string, data: unknown}>>} since
25
+ * @property {(ws: any, topic: string, sinceSeq: number, platform: import('svelte-adapter-uws').Platform) => Promise<void>} replay
26
+ * @property {() => Promise<void>} clear
27
+ * @property {(topic: string) => Promise<void>} clearTopic
28
+ */
29
+
30
+ /**
31
+ * Lua script for atomic publish: increment seq, store message, trim buffer.
32
+ *
33
+ * KEYS[1] = seq key
34
+ * KEYS[2] = buf key (sorted set)
35
+ * ARGV[1] = topic
36
+ * ARGV[2] = event
37
+ * ARGV[3] = data (JSON-encoded)
38
+ * ARGV[4] = maxSize
39
+ * ARGV[5] = ttl (seconds, 0 = no expiry)
40
+ *
41
+ * Returns the new sequence number.
42
+ */
43
+ const PUBLISH_SCRIPT = `
44
+ local seqKey = KEYS[1]
45
+ local bufKey = KEYS[2]
46
+ local topic = ARGV[1]
47
+ local event = ARGV[2]
48
+ local data = ARGV[3]
49
+ local maxSize = tonumber(ARGV[4])
50
+ local ttl = tonumber(ARGV[5])
51
+
52
+ local seq = redis.call('incr', seqKey)
53
+ local payload = cjson.encode({seq = seq, topic = topic, event = event, data = cjson.decode(data)})
54
+ redis.call('zadd', bufKey, seq, payload)
55
+
56
+ local count = redis.call('zcard', bufKey)
57
+ if count > maxSize then
58
+ redis.call('zremrangebyrank', bufKey, 0, count - maxSize - 1)
59
+ end
60
+
61
+ if ttl > 0 then
62
+ redis.call('expire', seqKey, ttl)
63
+ redis.call('expire', bufKey, ttl)
64
+ end
65
+
66
+ return seq
67
+ `;
68
+
69
+ /**
70
+ * Create a Redis-backed replay buffer.
71
+ *
72
+ * @param {import('./index.js').RedisClient} client
73
+ * @param {RedisReplayOptions} [options]
74
+ * @returns {RedisReplayBuffer}
75
+ */
76
+ export function createReplay(client, options = {}) {
77
+ if (options.size !== undefined) {
78
+ if (typeof options.size !== 'number' || options.size < 1 || !Number.isInteger(options.size)) {
79
+ throw new Error(`redis replay: size must be a positive integer, got ${options.size}`);
80
+ }
81
+ }
82
+ if (options.ttl !== undefined) {
83
+ if (typeof options.ttl !== 'number' || options.ttl < 0 || !Number.isInteger(options.ttl)) {
84
+ throw new Error(`redis replay: ttl must be a non-negative integer, got ${options.ttl}`);
85
+ }
86
+ }
87
+
88
+ const maxSize = options.size || 1000;
89
+ const ttl = options.ttl || 0;
90
+ const redis = client.redis;
91
+
92
+ function seqKey(topic) {
93
+ return client.key('replay:seq:' + topic);
94
+ }
95
+
96
+ function bufKey(topic) {
97
+ return client.key('replay:buf:' + topic);
98
+ }
99
+
100
+ return {
101
+ async publish(platform, topic, event, data) {
102
+ const sk = seqKey(topic);
103
+ const bk = bufKey(topic);
104
+
105
+ await redis.eval(
106
+ PUBLISH_SCRIPT, 2, sk, bk,
107
+ topic, event, JSON.stringify(data ?? null), maxSize, ttl
108
+ );
109
+
110
+ return platform.publish(topic, event, data);
111
+ },
112
+
113
+ async seq(topic) {
114
+ const val = await redis.get(seqKey(topic));
115
+ return val ? parseInt(val, 10) : 0;
116
+ },
117
+
118
+ async since(topic, since) {
119
+ // Get all entries with score > since
120
+ const raw = await redis.zrangebyscore(bufKey(topic), since + 1, '+inf');
121
+ return raw.map((entry) => JSON.parse(entry));
122
+ },
123
+
124
+ async replay(ws, topic, sinceSeq, platform) {
125
+ const missed = await this.since(topic, sinceSeq);
126
+ const replayTopic = '__replay:' + topic;
127
+
128
+ for (let i = 0; i < missed.length; i++) {
129
+ const msg = missed[i];
130
+ platform.send(ws, replayTopic, 'msg', {
131
+ seq: msg.seq,
132
+ event: msg.event,
133
+ data: msg.data
134
+ });
135
+ }
136
+ platform.send(ws, replayTopic, 'end', null);
137
+ },
138
+
139
+ async clear() {
140
+ // Find and delete all replay keys with our prefix
141
+ const pattern = client.key('replay:*');
142
+ let cursor = '0';
143
+ do {
144
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
145
+ cursor = nextCursor;
146
+ if (keys.length > 0) {
147
+ await redis.del(...keys);
148
+ }
149
+ } while (cursor !== '0');
150
+ },
151
+
152
+ async clearTopic(topic) {
153
+ await redis.del(seqKey(topic), bufKey(topic));
154
+ }
155
+ };
156
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Thrown when a connection to an external service (Redis, Postgres) fails.
3
+ */
4
+ export class ConnectionError extends Error {
5
+ /**
6
+ * @param {string} service - e.g. 'redis', 'postgres'
7
+ * @param {string} detail
8
+ * @param {Error} [cause]
9
+ */
10
+ constructor(service, detail, cause) {
11
+ super(`${service}: ${detail}`);
12
+ this.name = 'ConnectionError';
13
+ this.service = service;
14
+ if (cause) this.cause = cause;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Thrown when an operation times out.
20
+ */
21
+ export class TimeoutError extends Error {
22
+ /**
23
+ * @param {string} service
24
+ * @param {string} operation
25
+ * @param {number} ms
26
+ */
27
+ constructor(service, operation, ms) {
28
+ super(`${service}: ${operation} timed out after ${ms}ms`);
29
+ this.name = 'TimeoutError';
30
+ this.service = service;
31
+ this.ms = ms;
32
+ }
33
+ }