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.
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/package.json +99 -0
- package/postgres/index.d.ts +26 -0
- package/postgres/index.js +88 -0
- package/postgres/notify.d.ts +33 -0
- package/postgres/notify.js +214 -0
- package/postgres/replay.d.ts +56 -0
- package/postgres/replay.js +279 -0
- package/redis/cursor.d.ts +65 -0
- package/redis/cursor.js +365 -0
- package/redis/groups.d.ts +64 -0
- package/redis/groups.js +394 -0
- package/redis/index.d.ts +30 -0
- package/redis/index.js +100 -0
- package/redis/presence.d.ts +62 -0
- package/redis/presence.js +631 -0
- package/redis/pubsub.d.ts +30 -0
- package/redis/pubsub.js +153 -0
- package/redis/ratelimit.d.ts +39 -0
- package/redis/ratelimit.js +223 -0
- package/redis/replay.d.ts +47 -0
- package/redis/replay.js +156 -0
- package/shared/errors.js +33 -0
package/redis/pubsub.js
ADDED
|
@@ -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;
|
package/redis/replay.js
ADDED
|
@@ -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
|
+
}
|
package/shared/errors.js
ADDED
|
@@ -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
|
+
}
|