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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Platform } from 'svelte-adapter-uws';
|
|
2
|
+
import type { RedisClient } from './index.js';
|
|
3
|
+
|
|
4
|
+
export type GroupRole = 'member' | 'admin' | 'viewer';
|
|
5
|
+
|
|
6
|
+
export interface RedisGroupOptions {
|
|
7
|
+
/** Maximum members allowed. @default Infinity */
|
|
8
|
+
maxMembers?: number;
|
|
9
|
+
/** Initial group metadata. */
|
|
10
|
+
meta?: Record<string, any>;
|
|
11
|
+
/** Member entry TTL in seconds. Entries from crashed instances expire after this. @default 120 */
|
|
12
|
+
memberTtl?: number;
|
|
13
|
+
/** Called after a member joins. */
|
|
14
|
+
onJoin?: (ws: any, role: GroupRole) => void;
|
|
15
|
+
/** Called after a member leaves. */
|
|
16
|
+
onLeave?: (ws: any, role: GroupRole) => void;
|
|
17
|
+
/** Called when a join is rejected because the group is full. */
|
|
18
|
+
onFull?: (ws: any, role: GroupRole) => void;
|
|
19
|
+
/** Called when the group is closed. */
|
|
20
|
+
onClose?: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RedisGroup {
|
|
24
|
+
/** The group name. */
|
|
25
|
+
readonly name: string;
|
|
26
|
+
|
|
27
|
+
/** Get group metadata from Redis. */
|
|
28
|
+
getMeta(): Promise<Record<string, any>>;
|
|
29
|
+
|
|
30
|
+
/** Set group metadata in Redis. */
|
|
31
|
+
setMeta(meta: Record<string, any>): Promise<void>;
|
|
32
|
+
|
|
33
|
+
/** Add a member. Returns true on success, false if full or closed. */
|
|
34
|
+
join(ws: any, platform: Platform, role?: GroupRole): Promise<boolean>;
|
|
35
|
+
|
|
36
|
+
/** Remove a member. */
|
|
37
|
+
leave(ws: any, platform: Platform): Promise<void>;
|
|
38
|
+
|
|
39
|
+
/** Broadcast to all members, or filter by role. */
|
|
40
|
+
publish(platform: Platform, event: string, data?: any, role?: GroupRole): Promise<void>;
|
|
41
|
+
|
|
42
|
+
/** Send to a single member (validates membership). */
|
|
43
|
+
send(platform: Platform, ws: any, event: string, data?: any): void;
|
|
44
|
+
|
|
45
|
+
/** List members on this instance. */
|
|
46
|
+
localMembers(): Array<{ ws: any; role: GroupRole }>;
|
|
47
|
+
|
|
48
|
+
/** Total member count across all instances. */
|
|
49
|
+
count(): Promise<number>;
|
|
50
|
+
|
|
51
|
+
/** Check if a ws is a member on this instance. */
|
|
52
|
+
has(ws: any): boolean;
|
|
53
|
+
|
|
54
|
+
/** Dissolve the group, notify all members, clean up. */
|
|
55
|
+
close(platform: Platform): Promise<void>;
|
|
56
|
+
|
|
57
|
+
/** Stop the Redis subscriber. */
|
|
58
|
+
destroy(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a Redis-backed broadcast group.
|
|
63
|
+
*/
|
|
64
|
+
export function createGroup(client: RedisClient, name: string, options?: RedisGroupOptions): RedisGroup;
|
package/redis/groups.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed broadcast groups for svelte-adapter-uws.
|
|
3
|
+
*
|
|
4
|
+
* Same API as the core createGroup plugin, but membership and metadata
|
|
5
|
+
* are stored in Redis so groups work across multiple server instances.
|
|
6
|
+
*
|
|
7
|
+
* Local members (ws connections on this instance) still get tracked locally
|
|
8
|
+
* because WebSocket objects cannot be serialized. Cross-instance publish()
|
|
9
|
+
* uses Redis pub/sub to reach members on other instances.
|
|
10
|
+
*
|
|
11
|
+
* Storage layout:
|
|
12
|
+
* - Key `{prefix}group:{name}:meta` - hash (group metadata)
|
|
13
|
+
* - Key `{prefix}group:{name}:members` - hash (field=memberId, value=JSON {role, instanceId, ts})
|
|
14
|
+
* - Key `{prefix}group:{name}:closed` - string flag ("1" if closed)
|
|
15
|
+
* - Channel `{prefix}group:{name}:events` - pub/sub for cross-instance events
|
|
16
|
+
*
|
|
17
|
+
* @module svelte-adapter-uws-extensions/redis/groups
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
const VALID_ROLES = new Set(['member', 'admin', 'viewer']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lua script for atomic join: check capacity (excluding stale entries),
|
|
26
|
+
* clean up stale entries, and insert the new member in one roundtrip.
|
|
27
|
+
*
|
|
28
|
+
* KEYS[1] = members hash key
|
|
29
|
+
* ARGV[1] = maxMembers
|
|
30
|
+
* ARGV[2] = memberId
|
|
31
|
+
* ARGV[3] = member data JSON
|
|
32
|
+
* ARGV[4] = now (ms)
|
|
33
|
+
* ARGV[5] = memberTtl (ms)
|
|
34
|
+
*
|
|
35
|
+
* Returns 1 on success, 0 if full.
|
|
36
|
+
*/
|
|
37
|
+
const JOIN_SCRIPT = `
|
|
38
|
+
local key = KEYS[1]
|
|
39
|
+
local maxMembers = tonumber(ARGV[1])
|
|
40
|
+
local memberId = ARGV[2]
|
|
41
|
+
local memberData = ARGV[3]
|
|
42
|
+
local now = tonumber(ARGV[4])
|
|
43
|
+
local memberTtl = tonumber(ARGV[5])
|
|
44
|
+
|
|
45
|
+
local all = redis.call('hgetall', key)
|
|
46
|
+
local liveCount = 0
|
|
47
|
+
for i = 1, #all, 2 do
|
|
48
|
+
local ok, val = pcall(cjson.decode, all[i+1])
|
|
49
|
+
if ok and val.ts and (now - val.ts) <= memberTtl then
|
|
50
|
+
liveCount = liveCount + 1
|
|
51
|
+
else
|
|
52
|
+
redis.call('hdel', key, all[i])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if liveCount >= maxMembers then
|
|
57
|
+
return 0
|
|
58
|
+
end
|
|
59
|
+
redis.call('hset', key, memberId, memberData)
|
|
60
|
+
return 1
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {'member' | 'admin' | 'viewer'} GroupRole
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {Object} RedisGroupOptions
|
|
69
|
+
* @property {number} [maxMembers=Infinity] - Maximum members allowed
|
|
70
|
+
* @property {Record<string, any>} [meta] - Initial group metadata
|
|
71
|
+
* @property {number} [memberTtl=120] - Member entry TTL in seconds. Entries from crashed instances expire after this.
|
|
72
|
+
* @property {(ws: any, role: GroupRole) => void} [onJoin]
|
|
73
|
+
* @property {(ws: any, role: GroupRole) => void} [onLeave]
|
|
74
|
+
* @property {(ws: any, role: GroupRole) => void} [onFull]
|
|
75
|
+
* @property {() => void} [onClose]
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {Object} RedisGroup
|
|
80
|
+
* @property {string} name
|
|
81
|
+
* @property {() => Promise<Record<string, any>>} getMeta
|
|
82
|
+
* @property {(meta: Record<string, any>) => Promise<void>} setMeta
|
|
83
|
+
* @property {(ws: any, platform: import('svelte-adapter-uws').Platform, role?: GroupRole) => Promise<boolean>} join
|
|
84
|
+
* @property {(ws: any, platform: import('svelte-adapter-uws').Platform) => Promise<void>} leave
|
|
85
|
+
* @property {(platform: import('svelte-adapter-uws').Platform, event: string, data?: any, role?: GroupRole) => Promise<void>} publish
|
|
86
|
+
* @property {(platform: import('svelte-adapter-uws').Platform, ws: any, event: string, data?: any) => void} send
|
|
87
|
+
* @property {() => Array<{ws: any, role: GroupRole}>} localMembers - Members on this instance
|
|
88
|
+
* @property {() => Promise<number>} count - Total members across all instances
|
|
89
|
+
* @property {(ws: any) => boolean} has
|
|
90
|
+
* @property {(platform: import('svelte-adapter-uws').Platform) => Promise<void>} close
|
|
91
|
+
* @property {() => void} destroy - Stop subscriber
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a Redis-backed broadcast group.
|
|
96
|
+
*
|
|
97
|
+
* @param {import('./index.js').RedisClient} client
|
|
98
|
+
* @param {string} name
|
|
99
|
+
* @param {RedisGroupOptions} [options]
|
|
100
|
+
* @returns {RedisGroup}
|
|
101
|
+
*/
|
|
102
|
+
export function createGroup(client, name, options = {}) {
|
|
103
|
+
if (!name || typeof name !== 'string') {
|
|
104
|
+
throw new Error('redis group: name must be a non-empty string');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const maxMembers = options.maxMembers ?? Infinity;
|
|
108
|
+
const memberTtl = options.memberTtl ?? 120;
|
|
109
|
+
const memberTtlMs = memberTtl * 1000;
|
|
110
|
+
const onJoin = options.onJoin ?? null;
|
|
111
|
+
const onLeave = options.onLeave ?? null;
|
|
112
|
+
const onFull = options.onFull ?? null;
|
|
113
|
+
const onClose = options.onClose ?? null;
|
|
114
|
+
|
|
115
|
+
if (typeof maxMembers !== 'number' || (!Number.isFinite(maxMembers) && maxMembers !== Infinity) || maxMembers < 1) {
|
|
116
|
+
throw new Error('redis group: maxMembers must be a positive number or Infinity');
|
|
117
|
+
}
|
|
118
|
+
if (onJoin != null && typeof onJoin !== 'function') throw new Error('redis group: onJoin must be a function');
|
|
119
|
+
if (onLeave != null && typeof onLeave !== 'function') throw new Error('redis group: onLeave must be a function');
|
|
120
|
+
if (onFull != null && typeof onFull !== 'function') throw new Error('redis group: onFull must be a function');
|
|
121
|
+
if (onClose != null && typeof onClose !== 'function') throw new Error('redis group: onClose must be a function');
|
|
122
|
+
|
|
123
|
+
const instanceId = randomBytes(8).toString('hex');
|
|
124
|
+
const redis = client.redis;
|
|
125
|
+
|
|
126
|
+
const metaKey = client.key('group:' + name + ':meta');
|
|
127
|
+
const membersKey = client.key('group:' + name + ':members');
|
|
128
|
+
const closedKey = client.key('group:' + name + ':closed');
|
|
129
|
+
const eventChannel = client.key('group:' + name + ':events');
|
|
130
|
+
const internalTopic = '__group:' + name;
|
|
131
|
+
|
|
132
|
+
// Local member tracking (ws objects on this instance)
|
|
133
|
+
/** @type {Map<any, { role: GroupRole, memberId: string }>} */
|
|
134
|
+
const localMembers = new Map();
|
|
135
|
+
let memberCounter = 0;
|
|
136
|
+
|
|
137
|
+
// Set initial metadata
|
|
138
|
+
if (options.meta) {
|
|
139
|
+
redis.hmset(metaKey, options.meta).catch(() => {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Heartbeat: refresh timestamps on local member entries and
|
|
143
|
+
// remove stale entries from crashed instances.
|
|
144
|
+
const heartbeatTimer = setInterval(() => {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
for (const [, entry] of localMembers) {
|
|
147
|
+
const memberData = JSON.stringify({ role: entry.role, instanceId, ts: now });
|
|
148
|
+
redis.hset(membersKey, entry.memberId, memberData).catch(() => {});
|
|
149
|
+
}
|
|
150
|
+
// Clean up stale entries from dead instances so the hash
|
|
151
|
+
// does not grow forever after crashes.
|
|
152
|
+
redis.hgetall(membersKey).then((all) => {
|
|
153
|
+
if (!all) return;
|
|
154
|
+
for (const [field, v] of Object.entries(all)) {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(v);
|
|
157
|
+
if (parsed.ts && (now - parsed.ts) > memberTtlMs) {
|
|
158
|
+
redis.hdel(membersKey, field).catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
redis.hdel(membersKey, field).catch(() => {});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}).catch(() => {});
|
|
165
|
+
}, Math.max(memberTtlMs / 3, 5000));
|
|
166
|
+
if (heartbeatTimer.unref) heartbeatTimer.unref();
|
|
167
|
+
|
|
168
|
+
// Subscriber for cross-instance events
|
|
169
|
+
/** @type {import('ioredis').Redis | null} */
|
|
170
|
+
let subscriber = null;
|
|
171
|
+
/** @type {import('svelte-adapter-uws').Platform | null} */
|
|
172
|
+
let subscribedPlatform = null;
|
|
173
|
+
|
|
174
|
+
async function ensureSubscriber(platform) {
|
|
175
|
+
subscribedPlatform = platform;
|
|
176
|
+
if (subscriber) return;
|
|
177
|
+
subscriber = client.duplicate({ enableReadyCheck: false });
|
|
178
|
+
subscriber.on('message', (ch, message) => {
|
|
179
|
+
if (ch !== eventChannel) return;
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(message);
|
|
182
|
+
if (parsed.instanceId === instanceId) return;
|
|
183
|
+
if (subscribedPlatform) {
|
|
184
|
+
// Handle role-filtered events: deliver only to matching local members
|
|
185
|
+
if (parsed.event === '__role_filtered') {
|
|
186
|
+
const { event, data, role } = parsed.data;
|
|
187
|
+
for (const [ws, entry] of localMembers) {
|
|
188
|
+
if (entry.role === role) {
|
|
189
|
+
subscribedPlatform.send(ws, internalTopic, event, data);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} else if (parsed.event === 'close') {
|
|
193
|
+
// Remote close: clean up local members just like a local close().
|
|
194
|
+
// relay: false -- each worker has its own subscriber.
|
|
195
|
+
subscribedPlatform.publish(internalTopic, 'close', parsed.data, { relay: false });
|
|
196
|
+
for (const [ws] of localMembers) {
|
|
197
|
+
ws.unsubscribe(internalTopic);
|
|
198
|
+
}
|
|
199
|
+
localMembers.clear();
|
|
200
|
+
if (onClose) onClose();
|
|
201
|
+
} else {
|
|
202
|
+
// relay: false -- each worker has its own subscriber.
|
|
203
|
+
subscribedPlatform.publish(internalTopic, parsed.event, parsed.data, { relay: false });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// Malformed, skip
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
await subscriber.subscribe(eventChannel);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function publishEvent(event, data) {
|
|
214
|
+
const msg = JSON.stringify({ instanceId, event, data });
|
|
215
|
+
await redis.publish(eventChannel, msg).catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
get name() { return name; },
|
|
220
|
+
|
|
221
|
+
async getMeta() {
|
|
222
|
+
const raw = await redis.hgetall(metaKey);
|
|
223
|
+
return raw || {};
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async setMeta(meta) {
|
|
227
|
+
if (Object.keys(meta).length === 0) {
|
|
228
|
+
await redis.del(metaKey);
|
|
229
|
+
} else {
|
|
230
|
+
await redis.hmset(metaKey, meta);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async join(ws, platform, role = 'member') {
|
|
235
|
+
// Check closed
|
|
236
|
+
const closed = await redis.get(closedKey);
|
|
237
|
+
if (closed === '1') return false;
|
|
238
|
+
|
|
239
|
+
// Idempotent
|
|
240
|
+
if (localMembers.has(ws)) return true;
|
|
241
|
+
|
|
242
|
+
if (!VALID_ROLES.has(role)) {
|
|
243
|
+
throw new Error(`redis group "${name}": invalid role "${role}"`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const memberId = instanceId + ':' + (++memberCounter);
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const memberData = JSON.stringify({ role, instanceId, ts: now });
|
|
249
|
+
|
|
250
|
+
// Atomic capacity check + insert (skips stale entries from crashed instances)
|
|
251
|
+
if (Number.isFinite(maxMembers)) {
|
|
252
|
+
const result = await redis.eval(
|
|
253
|
+
JOIN_SCRIPT, 1, membersKey,
|
|
254
|
+
maxMembers, memberId, memberData, now, memberTtlMs
|
|
255
|
+
);
|
|
256
|
+
if (result === 0) {
|
|
257
|
+
if (onFull) onFull(ws, role);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// No capacity limit, just insert
|
|
262
|
+
await redis.hset(membersKey, memberId, memberData);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
localMembers.set(ws, { role, memberId });
|
|
266
|
+
|
|
267
|
+
// Ensure cross-instance subscriber is running
|
|
268
|
+
await ensureSubscriber(platform);
|
|
269
|
+
|
|
270
|
+
// Publish join before subscribing (joiner doesn't see own join)
|
|
271
|
+
platform.publish(internalTopic, 'join', { role });
|
|
272
|
+
await publishEvent('join', { role });
|
|
273
|
+
|
|
274
|
+
ws.subscribe(internalTopic);
|
|
275
|
+
|
|
276
|
+
// Send current member list to the joiner
|
|
277
|
+
const allRaw = await redis.hgetall(membersKey);
|
|
278
|
+
const membersList = [];
|
|
279
|
+
for (const v of Object.values(allRaw)) {
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(v);
|
|
282
|
+
// Filter out stale entries
|
|
283
|
+
if (parsed.ts && (now - parsed.ts) > memberTtlMs) continue;
|
|
284
|
+
membersList.push({ role: parsed.role });
|
|
285
|
+
} catch { /* skip corrupted */ }
|
|
286
|
+
}
|
|
287
|
+
platform.send(ws, internalTopic, 'members', membersList);
|
|
288
|
+
|
|
289
|
+
if (onJoin) onJoin(ws, role);
|
|
290
|
+
return true;
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async leave(ws, platform) {
|
|
294
|
+
const entry = localMembers.get(ws);
|
|
295
|
+
if (!entry) return;
|
|
296
|
+
|
|
297
|
+
localMembers.delete(ws);
|
|
298
|
+
ws.unsubscribe(internalTopic);
|
|
299
|
+
|
|
300
|
+
// Remove from Redis
|
|
301
|
+
await redis.hdel(membersKey, entry.memberId);
|
|
302
|
+
|
|
303
|
+
platform.publish(internalTopic, 'leave', { role: entry.role });
|
|
304
|
+
await publishEvent('leave', { role: entry.role });
|
|
305
|
+
|
|
306
|
+
if (onLeave) onLeave(ws, entry.role);
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async publish(platform, event, data, role) {
|
|
310
|
+
const closed = await redis.get(closedKey);
|
|
311
|
+
if (closed === '1') return;
|
|
312
|
+
|
|
313
|
+
if (role == null) {
|
|
314
|
+
// Broadcast to all via topic
|
|
315
|
+
platform.publish(internalTopic, event, data);
|
|
316
|
+
await publishEvent(event, data);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Role-filtered: send individually to local members with that role
|
|
321
|
+
for (const [ws, entry] of localMembers) {
|
|
322
|
+
if (entry.role === role) {
|
|
323
|
+
platform.send(ws, internalTopic, event, data);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// For remote instances, publish with role filter info
|
|
327
|
+
// Remote subscriber handler will filter by role locally
|
|
328
|
+
await publishEvent('__role_filtered', { event, data, role });
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
send(platform, ws, event, data) {
|
|
332
|
+
if (!localMembers.has(ws)) {
|
|
333
|
+
throw new Error(`redis group "${name}": ws is not a member`);
|
|
334
|
+
}
|
|
335
|
+
platform.send(ws, internalTopic, event, data);
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
localMembers() {
|
|
339
|
+
const result = [];
|
|
340
|
+
for (const [ws, entry] of localMembers) {
|
|
341
|
+
result.push({ ws, role: entry.role });
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async count() {
|
|
347
|
+
const all = await redis.hgetall(membersKey);
|
|
348
|
+
if (!all) return 0;
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
let liveCount = 0;
|
|
351
|
+
for (const v of Object.values(all)) {
|
|
352
|
+
try {
|
|
353
|
+
const parsed = JSON.parse(v);
|
|
354
|
+
if (parsed.ts && (now - parsed.ts) > memberTtlMs) continue;
|
|
355
|
+
liveCount++;
|
|
356
|
+
} catch { /* skip corrupted */ }
|
|
357
|
+
}
|
|
358
|
+
return liveCount;
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
has(ws) {
|
|
362
|
+
return localMembers.has(ws);
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
async close(platform) {
|
|
366
|
+
const alreadyClosed = await redis.get(closedKey);
|
|
367
|
+
if (alreadyClosed === '1') return;
|
|
368
|
+
|
|
369
|
+
await redis.set(closedKey, '1');
|
|
370
|
+
|
|
371
|
+
platform.publish(internalTopic, 'close', null);
|
|
372
|
+
await publishEvent('close', null);
|
|
373
|
+
|
|
374
|
+
for (const [ws] of localMembers) {
|
|
375
|
+
ws.unsubscribe(internalTopic);
|
|
376
|
+
}
|
|
377
|
+
localMembers.clear();
|
|
378
|
+
|
|
379
|
+
// Clean up Redis keys
|
|
380
|
+
await redis.del(membersKey);
|
|
381
|
+
|
|
382
|
+
if (onClose) onClose();
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
destroy() {
|
|
386
|
+
clearInterval(heartbeatTimer);
|
|
387
|
+
if (subscriber) {
|
|
388
|
+
subscriber.quit().catch(() => subscriber.disconnect());
|
|
389
|
+
subscriber = null;
|
|
390
|
+
}
|
|
391
|
+
subscribedPlatform = null;
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
package/redis/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Redis, RedisOptions } from 'ioredis';
|
|
2
|
+
|
|
3
|
+
export interface RedisClientOptions {
|
|
4
|
+
/** Redis connection URL. @default 'redis://localhost:6379' */
|
|
5
|
+
url?: string;
|
|
6
|
+
/** Prefix for all keys written by extensions. @default '' */
|
|
7
|
+
keyPrefix?: string;
|
|
8
|
+
/** Listen for `sveltekit:shutdown` and disconnect. @default true */
|
|
9
|
+
autoShutdown?: boolean;
|
|
10
|
+
/** Extra ioredis options (merged on top of URL parsing). */
|
|
11
|
+
options?: RedisOptions;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RedisClient {
|
|
15
|
+
/** The underlying ioredis instance. */
|
|
16
|
+
readonly redis: Redis;
|
|
17
|
+
/** The key prefix. */
|
|
18
|
+
readonly keyPrefix: string;
|
|
19
|
+
/** Prefix a key: `keyPrefix + key`. */
|
|
20
|
+
key(key: string): string;
|
|
21
|
+
/** Create a new connection with the same config. Pass overrides to change options (e.g. `{ enableReadyCheck: false }` for subscriber clients). */
|
|
22
|
+
duplicate(overrides?: RedisOptions): Redis;
|
|
23
|
+
/** Gracefully disconnect all connections. */
|
|
24
|
+
quit(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a Redis client with lifecycle management.
|
|
29
|
+
*/
|
|
30
|
+
export function createRedisClient(options?: RedisClientOptions): RedisClient;
|
package/redis/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis client factory for svelte-adapter-uws-extensions.
|
|
3
|
+
*
|
|
4
|
+
* Wraps ioredis with lifecycle management and graceful shutdown
|
|
5
|
+
* via the SvelteKit `sveltekit:shutdown` event.
|
|
6
|
+
*
|
|
7
|
+
* @module svelte-adapter-uws-extensions/redis
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import Redis from 'ioredis';
|
|
11
|
+
import { ConnectionError } from '../shared/errors.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} RedisClientOptions
|
|
15
|
+
* @property {string} [url='redis://localhost:6379'] - Redis connection URL
|
|
16
|
+
* @property {string} [keyPrefix=''] - Prefix for all keys written by extensions
|
|
17
|
+
* @property {boolean} [autoShutdown=true] - Listen for `sveltekit:shutdown` and disconnect
|
|
18
|
+
* @property {import('ioredis').RedisOptions} [options] - Extra ioredis options (merged on top of URL parsing)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} RedisClient
|
|
23
|
+
* @property {import('ioredis').Redis} redis - The underlying ioredis instance
|
|
24
|
+
* @property {string} keyPrefix - The key prefix
|
|
25
|
+
* @property {(key: string) => string} key - Prefix a key: `keyPrefix + key`
|
|
26
|
+
* @property {(overrides?: import('ioredis').RedisOptions) => import('ioredis').Redis} duplicate - Create a new connection with the same config (for subscribers)
|
|
27
|
+
* @property {() => Promise<void>} quit - Gracefully disconnect
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a Redis client.
|
|
32
|
+
*
|
|
33
|
+
* @param {RedisClientOptions} [options]
|
|
34
|
+
* @returns {RedisClient}
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```js
|
|
38
|
+
* import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
|
|
39
|
+
*
|
|
40
|
+
* export const redis = createRedisClient({
|
|
41
|
+
* url: 'redis://localhost:6379',
|
|
42
|
+
* keyPrefix: 'myapp:'
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function createRedisClient(options = {}) {
|
|
47
|
+
const url = options.url || 'redis://localhost:6379';
|
|
48
|
+
const keyPrefix = options.keyPrefix || '';
|
|
49
|
+
const autoShutdown = options.autoShutdown !== false;
|
|
50
|
+
|
|
51
|
+
/** @type {import('ioredis').RedisOptions} */
|
|
52
|
+
const redisOpts = {
|
|
53
|
+
maxRetriesPerRequest: null,
|
|
54
|
+
enableReadyCheck: true,
|
|
55
|
+
lazyConnect: false,
|
|
56
|
+
...(options.options || {})
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let redis;
|
|
60
|
+
try {
|
|
61
|
+
redis = new Redis(url, redisOpts);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new ConnectionError('redis', `failed to create client for ${url}`, err);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Track duplicate connections for cleanup
|
|
67
|
+
/** @type {import('ioredis').Redis[]} */
|
|
68
|
+
const duplicates = [];
|
|
69
|
+
|
|
70
|
+
/** @type {boolean} */
|
|
71
|
+
let shuttingDown = false;
|
|
72
|
+
|
|
73
|
+
async function quit() {
|
|
74
|
+
if (shuttingDown) return;
|
|
75
|
+
shuttingDown = true;
|
|
76
|
+
const all = [redis, ...duplicates];
|
|
77
|
+
await Promise.allSettled(all.map((c) => c.quit().catch(() => c.disconnect())));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (autoShutdown && typeof process !== 'undefined') {
|
|
81
|
+
process.once('sveltekit:shutdown', quit);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
redis,
|
|
86
|
+
keyPrefix,
|
|
87
|
+
|
|
88
|
+
key(k) {
|
|
89
|
+
return keyPrefix + k;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
duplicate(overrides) {
|
|
93
|
+
const dup = overrides ? redis.duplicate(overrides) : redis.duplicate();
|
|
94
|
+
duplicates.push(dup);
|
|
95
|
+
return dup;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
quit
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Platform } from 'svelte-adapter-uws';
|
|
2
|
+
import type { RedisClient } from './index.js';
|
|
3
|
+
|
|
4
|
+
export interface RedisPresenceOptions {
|
|
5
|
+
/** Field in selected data for user dedup. @default 'id' */
|
|
6
|
+
key?: string;
|
|
7
|
+
/** Extract public fields from userData. @default identity */
|
|
8
|
+
select?: (userData: any) => Record<string, any>;
|
|
9
|
+
/** Heartbeat interval in ms (refresh TTL). @default 30000 */
|
|
10
|
+
heartbeat?: number;
|
|
11
|
+
/** TTL in seconds for presence hash entries. @default 90 */
|
|
12
|
+
ttl?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RedisPresenceTracker {
|
|
16
|
+
/**
|
|
17
|
+
* Add a connection to a topic's presence.
|
|
18
|
+
* Ignores `__`-prefixed topics. Idempotent.
|
|
19
|
+
*/
|
|
20
|
+
join(ws: any, topic: string, platform: Platform): Promise<void>;
|
|
21
|
+
|
|
22
|
+
/** Remove a connection from a specific topic, or all topics if omitted. */
|
|
23
|
+
leave(ws: any, platform: Platform, topic?: string): Promise<void>;
|
|
24
|
+
|
|
25
|
+
/** Send current presence list without joining. */
|
|
26
|
+
sync(ws: any, topic: string, platform: Platform): Promise<void>;
|
|
27
|
+
|
|
28
|
+
/** Get the current presence list for a topic. */
|
|
29
|
+
list(topic: string): Promise<Record<string, any>[]>;
|
|
30
|
+
|
|
31
|
+
/** Get the number of unique users present on a topic. */
|
|
32
|
+
count(topic: string): Promise<number>;
|
|
33
|
+
|
|
34
|
+
/** Clear all presence state. */
|
|
35
|
+
clear(): Promise<void>;
|
|
36
|
+
|
|
37
|
+
/** Stop heartbeat timer and Redis subscriber. */
|
|
38
|
+
destroy(): void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ready-made WebSocket hooks for zero-config presence.
|
|
42
|
+
*
|
|
43
|
+
* `subscribe` handles both regular topics (calls `join`) and `__presence:*`
|
|
44
|
+
* topics (calls `sync` so the client gets the current list immediately).
|
|
45
|
+
* `close` calls `leave`.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```js
|
|
49
|
+
* import { presence } from '$lib/server/presence';
|
|
50
|
+
* export const { subscribe, close } = presence.hooks;
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
hooks: {
|
|
54
|
+
subscribe(ws: any, topic: string, ctx: { platform: Platform }): Promise<void>;
|
|
55
|
+
close(ws: any, ctx: { platform: Platform }): Promise<void>;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a Redis-backed presence tracker.
|
|
61
|
+
*/
|
|
62
|
+
export function createPresence(client: RedisClient, options?: RedisPresenceOptions): RedisPresenceTracker;
|