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,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres-backed replay buffer for svelte-adapter-uws.
|
|
3
|
+
*
|
|
4
|
+
* Same API as the core createReplay plugin, but stores messages in a
|
|
5
|
+
* Postgres table for durable history that survives restarts.
|
|
6
|
+
*
|
|
7
|
+
* Table schema (auto-created if autoMigrate is true):
|
|
8
|
+
* ws_replay (
|
|
9
|
+
* ws_replay_id BIGSERIAL PRIMARY KEY,
|
|
10
|
+
* topic TEXT NOT NULL,
|
|
11
|
+
* seq BIGINT NOT NULL,
|
|
12
|
+
* event TEXT NOT NULL,
|
|
13
|
+
* data JSONB,
|
|
14
|
+
* created_date TIMESTAMPTZ DEFAULT now()
|
|
15
|
+
* )
|
|
16
|
+
* + index on (topic, seq)
|
|
17
|
+
*
|
|
18
|
+
* ws_replay_seq (
|
|
19
|
+
* topic TEXT PRIMARY KEY,
|
|
20
|
+
* seq BIGINT NOT NULL DEFAULT 0
|
|
21
|
+
* )
|
|
22
|
+
*
|
|
23
|
+
* Sequences are generated atomically via the _seq table using
|
|
24
|
+
* INSERT ... ON CONFLICT DO UPDATE, so they are safe across multiple
|
|
25
|
+
* server instances without races.
|
|
26
|
+
*
|
|
27
|
+
* @module svelte-adapter-uws-extensions/postgres/replay
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} PgReplayOptions
|
|
32
|
+
* @property {string} [table='ws_replay'] - Table name
|
|
33
|
+
* @property {number} [size=1000] - Max messages per topic
|
|
34
|
+
* @property {number} [ttl=0] - TTL in seconds (0 = no expiry). Rows older than this are cleaned up periodically.
|
|
35
|
+
* @property {boolean} [autoMigrate=true] - Auto-create table on first use
|
|
36
|
+
* @property {number} [cleanupInterval=60000] - How often to run cleanup (ms). 0 to disable.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} PgReplayBuffer
|
|
41
|
+
* @property {(platform: import('svelte-adapter-uws').Platform, topic: string, event: string, data?: unknown) => Promise<boolean>} publish
|
|
42
|
+
* @property {(topic: string) => Promise<number>} seq
|
|
43
|
+
* @property {(topic: string, since: number) => Promise<Array<{seq: number, topic: string, event: string, data: unknown}>>} since
|
|
44
|
+
* @property {(ws: any, topic: string, sinceSeq: number, platform: import('svelte-adapter-uws').Platform) => Promise<void>} replay
|
|
45
|
+
* @property {() => Promise<void>} clear
|
|
46
|
+
* @property {(topic: string) => Promise<void>} clearTopic
|
|
47
|
+
* @property {() => void} destroy - Stop cleanup timer
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a Postgres-backed replay buffer.
|
|
52
|
+
*
|
|
53
|
+
* @param {import('./index.js').PgClient} client
|
|
54
|
+
* @param {PgReplayOptions} [options]
|
|
55
|
+
* @returns {PgReplayBuffer}
|
|
56
|
+
*/
|
|
57
|
+
export function createReplay(client, options = {}) {
|
|
58
|
+
if (options.size !== undefined) {
|
|
59
|
+
if (typeof options.size !== 'number' || options.size < 1 || !Number.isInteger(options.size)) {
|
|
60
|
+
throw new Error(`postgres replay: size must be a positive integer, got ${options.size}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (options.ttl !== undefined) {
|
|
64
|
+
if (typeof options.ttl !== 'number' || options.ttl < 0 || !Number.isInteger(options.ttl)) {
|
|
65
|
+
throw new Error(`postgres replay: ttl must be a non-negative integer, got ${options.ttl}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const table = options.table || 'ws_replay';
|
|
70
|
+
const seqTable = table + '_seq';
|
|
71
|
+
const pkCol = table + '_id';
|
|
72
|
+
const maxSize = options.size || 1000;
|
|
73
|
+
const ttl = options.ttl || 0;
|
|
74
|
+
const autoMigrate = options.autoMigrate !== false;
|
|
75
|
+
const cleanupInterval = options.cleanupInterval !== undefined ? options.cleanupInterval : 60000;
|
|
76
|
+
|
|
77
|
+
// Validate table name to prevent SQL injection (alphanumeric + underscore only)
|
|
78
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) {
|
|
79
|
+
throw new Error(`postgres replay: invalid table name "${table}"`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let migrated = false;
|
|
83
|
+
|
|
84
|
+
async function ensureTable() {
|
|
85
|
+
if (migrated || !autoMigrate) return;
|
|
86
|
+
await client.query(`
|
|
87
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
88
|
+
${pkCol} BIGSERIAL PRIMARY KEY,
|
|
89
|
+
topic TEXT NOT NULL,
|
|
90
|
+
seq BIGINT NOT NULL,
|
|
91
|
+
event TEXT NOT NULL,
|
|
92
|
+
data JSONB,
|
|
93
|
+
created_date TIMESTAMPTZ DEFAULT now()
|
|
94
|
+
)
|
|
95
|
+
`);
|
|
96
|
+
await client.query(`
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_${table}_topic_seq ON ${table} (topic, seq)
|
|
98
|
+
`);
|
|
99
|
+
// Atomic per-topic sequence counter table
|
|
100
|
+
await client.query(`
|
|
101
|
+
CREATE TABLE IF NOT EXISTS ${seqTable} (
|
|
102
|
+
topic TEXT PRIMARY KEY,
|
|
103
|
+
seq BIGINT NOT NULL DEFAULT 0
|
|
104
|
+
)
|
|
105
|
+
`);
|
|
106
|
+
migrated = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Atomically get the next sequence number for a topic.
|
|
111
|
+
* Uses INSERT ... ON CONFLICT DO UPDATE for cross-instance safety.
|
|
112
|
+
*/
|
|
113
|
+
async function nextSeq(topic) {
|
|
114
|
+
const res = await client.query(
|
|
115
|
+
`INSERT INTO ${seqTable} (topic, seq)
|
|
116
|
+
VALUES ($1, 1)
|
|
117
|
+
ON CONFLICT (topic)
|
|
118
|
+
DO UPDATE
|
|
119
|
+
SET seq = ${seqTable}.seq + 1
|
|
120
|
+
RETURNING seq`,
|
|
121
|
+
[topic]
|
|
122
|
+
);
|
|
123
|
+
return parseInt(res.rows[0].seq, 10);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Local publish counter per topic -- avoids COUNT(*) on every publish
|
|
127
|
+
/** @type {Map<string, number>} */
|
|
128
|
+
const publishCounts = new Map();
|
|
129
|
+
|
|
130
|
+
// Periodic cleanup
|
|
131
|
+
let cleanupTimer = null;
|
|
132
|
+
if (cleanupInterval > 0) {
|
|
133
|
+
cleanupTimer = setInterval(async () => {
|
|
134
|
+
try {
|
|
135
|
+
await ensureTable();
|
|
136
|
+
|
|
137
|
+
// Trim by size: for each topic, keep only the newest `maxSize` rows
|
|
138
|
+
await client.query(`
|
|
139
|
+
DELETE FROM ${table}
|
|
140
|
+
WHERE ${pkCol} IN (
|
|
141
|
+
SELECT ${pkCol}
|
|
142
|
+
FROM (SELECT ${pkCol},
|
|
143
|
+
ROW_NUMBER() OVER (PARTITION BY topic ORDER BY seq DESC) AS row_num
|
|
144
|
+
FROM ${table}) ranked
|
|
145
|
+
WHERE row_num > $1)
|
|
146
|
+
`, [maxSize]);
|
|
147
|
+
|
|
148
|
+
// Trim by TTL
|
|
149
|
+
if (ttl > 0) {
|
|
150
|
+
await client.query(
|
|
151
|
+
`DELETE FROM ${table}
|
|
152
|
+
WHERE created_date < now() - interval '1 second' * $1`,
|
|
153
|
+
[ttl]
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Reset local counters after cleanup
|
|
158
|
+
publishCounts.clear();
|
|
159
|
+
} catch {
|
|
160
|
+
// Cleanup failures are non-fatal
|
|
161
|
+
}
|
|
162
|
+
}, cleanupInterval);
|
|
163
|
+
if (cleanupTimer.unref) cleanupTimer.unref();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
async publish(platform, topic, event, data) {
|
|
168
|
+
await ensureTable();
|
|
169
|
+
const seq = await nextSeq(topic);
|
|
170
|
+
|
|
171
|
+
await client.query(
|
|
172
|
+
`INSERT INTO ${table} (topic, seq, event, data)
|
|
173
|
+
VALUES ($1, $2, $3, $4)`,
|
|
174
|
+
[topic, seq, event, JSON.stringify(data)]
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Inline trim: use local counter to avoid COUNT(*) on every publish.
|
|
178
|
+
// On first publish per topic in this process, seed from the database
|
|
179
|
+
// so a fresh/restarted instance trims correctly.
|
|
180
|
+
let count;
|
|
181
|
+
if (publishCounts.has(topic)) {
|
|
182
|
+
count = publishCounts.get(topic) + 1;
|
|
183
|
+
} else {
|
|
184
|
+
const res = await client.query(
|
|
185
|
+
`SELECT COUNT(*)::int AS message_count
|
|
186
|
+
FROM ${table}
|
|
187
|
+
WHERE topic = $1`,
|
|
188
|
+
[topic]
|
|
189
|
+
);
|
|
190
|
+
count = parseInt(res.rows[0].message_count, 10);
|
|
191
|
+
}
|
|
192
|
+
publishCounts.set(topic, count);
|
|
193
|
+
if (count > maxSize) {
|
|
194
|
+
await client.query(`
|
|
195
|
+
DELETE FROM ${table}
|
|
196
|
+
WHERE topic = $1
|
|
197
|
+
AND ${pkCol} NOT IN (
|
|
198
|
+
SELECT ${pkCol}
|
|
199
|
+
FROM ${table}
|
|
200
|
+
WHERE topic = $1
|
|
201
|
+
ORDER BY seq DESC
|
|
202
|
+
LIMIT $2)
|
|
203
|
+
`, [topic, maxSize]);
|
|
204
|
+
publishCounts.set(topic, maxSize);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return platform.publish(topic, event, data);
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async seq(topic) {
|
|
211
|
+
await ensureTable();
|
|
212
|
+
const res = await client.query(
|
|
213
|
+
`SELECT COALESCE(MAX(seq), 0)::int AS max_seq
|
|
214
|
+
FROM ${table}
|
|
215
|
+
WHERE topic = $1`,
|
|
216
|
+
[topic]
|
|
217
|
+
);
|
|
218
|
+
return parseInt(res.rows[0].max_seq, 10);
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async since(topic, since) {
|
|
222
|
+
await ensureTable();
|
|
223
|
+
const res = await client.query(
|
|
224
|
+
`SELECT seq, topic, event, data
|
|
225
|
+
FROM ${table}
|
|
226
|
+
WHERE topic = $1
|
|
227
|
+
AND seq > $2
|
|
228
|
+
ORDER BY seq ASC`,
|
|
229
|
+
[topic, since]
|
|
230
|
+
);
|
|
231
|
+
return res.rows.map((row) => ({
|
|
232
|
+
seq: parseInt(row.seq, 10),
|
|
233
|
+
topic: row.topic,
|
|
234
|
+
event: row.event,
|
|
235
|
+
data: row.data
|
|
236
|
+
}));
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
async replay(ws, topic, sinceSeq, platform) {
|
|
240
|
+
const missed = await this.since(topic, sinceSeq);
|
|
241
|
+
const replayTopic = '__replay:' + topic;
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < missed.length; i++) {
|
|
244
|
+
const msg = missed[i];
|
|
245
|
+
platform.send(ws, replayTopic, 'msg', {
|
|
246
|
+
seq: msg.seq,
|
|
247
|
+
event: msg.event,
|
|
248
|
+
data: msg.data
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
platform.send(ws, replayTopic, 'end', null);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async clear() {
|
|
255
|
+
await ensureTable();
|
|
256
|
+
await client.query(`DELETE FROM ${table}`);
|
|
257
|
+
await client.query(`DELETE FROM ${seqTable}`);
|
|
258
|
+
publishCounts.clear();
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async clearTopic(topic) {
|
|
262
|
+
await ensureTable();
|
|
263
|
+
await client.query(
|
|
264
|
+
`DELETE FROM ${table}
|
|
265
|
+
WHERE topic = $1`, [topic]);
|
|
266
|
+
await client.query(
|
|
267
|
+
`DELETE FROM ${seqTable}
|
|
268
|
+
WHERE topic = $1`, [topic]);
|
|
269
|
+
publishCounts.delete(topic);
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
destroy() {
|
|
273
|
+
if (cleanupTimer) {
|
|
274
|
+
clearInterval(cleanupTimer);
|
|
275
|
+
cleanupTimer = null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Platform } from 'svelte-adapter-uws';
|
|
2
|
+
import type { RedisClient } from './index.js';
|
|
3
|
+
|
|
4
|
+
export interface RedisCursorOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Minimum ms between broadcasts per user per topic.
|
|
7
|
+
* Trailing-edge timer ensures the final position is always sent.
|
|
8
|
+
* @default 50
|
|
9
|
+
*/
|
|
10
|
+
throttle?: number;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract user-identifying data from userData.
|
|
14
|
+
* Broadcast alongside cursor data so other clients know who the cursor belongs to.
|
|
15
|
+
* @default identity
|
|
16
|
+
*/
|
|
17
|
+
select?: (userData: any) => any;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* TTL in seconds for Redis hash entries.
|
|
21
|
+
* Entries are refreshed on every broadcast. Stale cursors from crashed
|
|
22
|
+
* instances are cleaned up automatically after this period.
|
|
23
|
+
* @default 30
|
|
24
|
+
*/
|
|
25
|
+
ttl?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CursorEntry {
|
|
29
|
+
/** Unique connection key. */
|
|
30
|
+
key: string;
|
|
31
|
+
/** Selected user data. */
|
|
32
|
+
user: any;
|
|
33
|
+
/** Latest cursor/position data. */
|
|
34
|
+
data: any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RedisCursorTracker {
|
|
38
|
+
/**
|
|
39
|
+
* Broadcast a cursor position update. Throttled per user per topic.
|
|
40
|
+
* Call this from your `message` hook when you receive cursor data.
|
|
41
|
+
*/
|
|
42
|
+
update(ws: any, topic: string, data: any, platform: Platform): void;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Remove a connection's cursor state from a specific topic, or all topics if omitted.
|
|
46
|
+
* Call this from your `close` hook.
|
|
47
|
+
*/
|
|
48
|
+
remove(ws: any, platform: Platform, topic?: string): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get current cursor positions for a topic across all instances.
|
|
52
|
+
*/
|
|
53
|
+
list(topic: string): Promise<CursorEntry[]>;
|
|
54
|
+
|
|
55
|
+
/** Clear all cursor state (local and Redis). */
|
|
56
|
+
clear(): Promise<void>;
|
|
57
|
+
|
|
58
|
+
/** Stop the Redis subscriber and clear local timers. */
|
|
59
|
+
destroy(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a Redis-backed cursor tracker.
|
|
64
|
+
*/
|
|
65
|
+
export function createCursor(client: RedisClient, options?: RedisCursorOptions): RedisCursorTracker;
|
package/redis/cursor.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed cursor / ephemeral state plugin for svelte-adapter-uws.
|
|
3
|
+
*
|
|
4
|
+
* Same API as the core createCursor plugin, but cursor positions are shared
|
|
5
|
+
* across instances via Redis. Each instance throttles locally (same
|
|
6
|
+
* leading/trailing edge logic as the core), then relays broadcasts through
|
|
7
|
+
* Redis pub/sub so subscribers on other instances see cursor updates.
|
|
8
|
+
*
|
|
9
|
+
* Storage layout:
|
|
10
|
+
* - Hash `{prefix}cursor:{topic}` - field = connectionKey, value = JSON { user, data }
|
|
11
|
+
* - Channel `{prefix}cursor:events` - pub/sub for update/remove relay
|
|
12
|
+
*
|
|
13
|
+
* Hash entries expire via TTL so stale cursors from crashed instances
|
|
14
|
+
* get cleaned up automatically.
|
|
15
|
+
*
|
|
16
|
+
* @module svelte-adapter-uws-extensions/redis/cursor
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} RedisCursorOptions
|
|
23
|
+
* @property {number} [throttle=50] - Minimum ms between broadcasts per user per topic.
|
|
24
|
+
* Trailing-edge timer fires to ensure the final position is always sent.
|
|
25
|
+
* @property {(userData: any) => any} [select] - Extract user-identifying data from userData.
|
|
26
|
+
* Defaults to the full userData.
|
|
27
|
+
* @property {number} [ttl=30] - TTL in seconds for hash entries. Should be longer than
|
|
28
|
+
* the expected gap between updates. Entries are refreshed on every broadcast.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} CursorEntry
|
|
33
|
+
* @property {string} key - Unique connection key.
|
|
34
|
+
* @property {any} user - Selected user data.
|
|
35
|
+
* @property {any} data - Latest cursor/position data.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} RedisCursorTracker
|
|
40
|
+
* @property {(ws: any, topic: string, data: any, platform: import('svelte-adapter-uws').Platform) => void} update
|
|
41
|
+
* @property {(ws: any, platform: import('svelte-adapter-uws').Platform, topic?: string) => Promise<void>} remove
|
|
42
|
+
* @property {(topic: string) => Promise<CursorEntry[]>} list
|
|
43
|
+
* @property {() => Promise<void>} clear
|
|
44
|
+
* @property {() => void} destroy - Stop the Redis subscriber
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a Redis-backed cursor tracker.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('./index.js').RedisClient} client
|
|
51
|
+
* @param {RedisCursorOptions} [options]
|
|
52
|
+
* @returns {RedisCursorTracker}
|
|
53
|
+
*/
|
|
54
|
+
export function createCursor(client, options = {}) {
|
|
55
|
+
const throttleMs = options.throttle ?? 50;
|
|
56
|
+
const select = options.select || ((userData) => userData);
|
|
57
|
+
const cursorTtl = options.ttl || 30;
|
|
58
|
+
|
|
59
|
+
if (typeof throttleMs !== 'number' || !Number.isFinite(throttleMs) || throttleMs < 0) {
|
|
60
|
+
throw new Error('redis cursor: throttle must be a non-negative number');
|
|
61
|
+
}
|
|
62
|
+
if (typeof select !== 'function') {
|
|
63
|
+
throw new Error('redis cursor: select must be a function');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const instanceId = randomBytes(8).toString('hex');
|
|
67
|
+
const redis = client.redis;
|
|
68
|
+
const channel = client.key('cursor:events');
|
|
69
|
+
|
|
70
|
+
let connCounter = 0;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Per-ws state: their key and which topics they have cursor state on.
|
|
74
|
+
* @type {Map<any, { key: string, user: any, topics: Set<string> }>}
|
|
75
|
+
*/
|
|
76
|
+
const wsState = new Map();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Per-topic cursor positions (local throttle state).
|
|
80
|
+
* @type {Map<string, Map<string, { user: any, data: any, lastBroadcast: number, timer: any }>>}
|
|
81
|
+
*/
|
|
82
|
+
const topics = new Map();
|
|
83
|
+
|
|
84
|
+
// Redis subscriber for cross-instance relay
|
|
85
|
+
/** @type {import('ioredis').Redis | null} */
|
|
86
|
+
let subscriber = null;
|
|
87
|
+
/** @type {import('svelte-adapter-uws').Platform | null} */
|
|
88
|
+
let activePlatform = null;
|
|
89
|
+
let subscriberReady = false;
|
|
90
|
+
|
|
91
|
+
function ensureSubscriber(platform) {
|
|
92
|
+
activePlatform = platform;
|
|
93
|
+
if (subscriber) return;
|
|
94
|
+
const sub = client.duplicate({ enableReadyCheck: false });
|
|
95
|
+
subscriber = sub;
|
|
96
|
+
sub.on('message', (ch, message) => {
|
|
97
|
+
if (ch !== channel) return;
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(message);
|
|
100
|
+
if (parsed.instanceId === instanceId) return;
|
|
101
|
+
// relay: false -- each worker has its own subscriber,
|
|
102
|
+
// so no need to IPC-relay to sibling workers.
|
|
103
|
+
if (activePlatform) {
|
|
104
|
+
activePlatform.publish(
|
|
105
|
+
'__cursor:' + parsed.topic,
|
|
106
|
+
parsed.event,
|
|
107
|
+
parsed.payload,
|
|
108
|
+
{ relay: false }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Malformed, skip
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
sub.subscribe(channel).then(() => {
|
|
116
|
+
subscriberReady = true;
|
|
117
|
+
}).catch(() => {
|
|
118
|
+
// Subscribe failed -- clean up so the next call can retry
|
|
119
|
+
sub.quit().catch(() => sub.disconnect());
|
|
120
|
+
if (subscriber === sub) {
|
|
121
|
+
subscriber = null;
|
|
122
|
+
subscriberReady = false;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cursorTtlMs = cursorTtl * 1000;
|
|
128
|
+
|
|
129
|
+
// Track topics with local activity for periodic stale field cleanup
|
|
130
|
+
/** @type {Set<string>} */
|
|
131
|
+
const activeTopics = new Set();
|
|
132
|
+
|
|
133
|
+
// Periodic cleanup: remove stale fields from dead instances
|
|
134
|
+
const cleanupInterval = Math.max(cursorTtlMs, 10000);
|
|
135
|
+
const cleanupTimer = setInterval(() => {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
for (const topic of activeTopics) {
|
|
138
|
+
redis.hgetall(client.key('cursor:' + topic)).then((all) => {
|
|
139
|
+
if (!all) return;
|
|
140
|
+
for (const [field, v] of Object.entries(all)) {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(v);
|
|
143
|
+
if (parsed.ts && (now - parsed.ts) > cursorTtlMs) {
|
|
144
|
+
redis.hdel(client.key('cursor:' + topic), field).catch(() => {});
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
redis.hdel(client.key('cursor:' + topic), field).catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}).catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
}, cleanupInterval);
|
|
153
|
+
if (cleanupTimer.unref) cleanupTimer.unref();
|
|
154
|
+
|
|
155
|
+
function hashKey(topic) {
|
|
156
|
+
return client.key('cursor:' + topic);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getWsState(ws) {
|
|
160
|
+
let state = wsState.get(ws);
|
|
161
|
+
if (!state) {
|
|
162
|
+
state = {
|
|
163
|
+
key: instanceId + ':' + (++connCounter),
|
|
164
|
+
user: select(typeof ws.getUserData === 'function' ? ws.getUserData() : {}),
|
|
165
|
+
topics: new Set()
|
|
166
|
+
};
|
|
167
|
+
wsState.set(ws, state);
|
|
168
|
+
}
|
|
169
|
+
return state;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Broadcast locally + relay to other instances via Redis.
|
|
174
|
+
*/
|
|
175
|
+
function broadcast(topic, key, user, data, platform) {
|
|
176
|
+
// Local broadcast
|
|
177
|
+
platform.publish('__cursor:' + topic, 'update', { key, user, data });
|
|
178
|
+
|
|
179
|
+
// Persist to Redis hash with timestamp for per-entry staleness detection
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
redis.hset(hashKey(topic), key, JSON.stringify({ user, data, ts: now })).catch(() => {});
|
|
182
|
+
redis.expire(hashKey(topic), cursorTtl).catch(() => {});
|
|
183
|
+
|
|
184
|
+
// Relay to other instances
|
|
185
|
+
const msg = JSON.stringify({
|
|
186
|
+
instanceId,
|
|
187
|
+
topic,
|
|
188
|
+
event: 'update',
|
|
189
|
+
payload: { key, user, data }
|
|
190
|
+
});
|
|
191
|
+
redis.publish(channel, msg).catch(() => {});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function broadcastRemove(topic, key, platform) {
|
|
195
|
+
platform.publish('__cursor:' + topic, 'remove', { key });
|
|
196
|
+
|
|
197
|
+
redis.hdel(hashKey(topic), key).catch(() => {});
|
|
198
|
+
|
|
199
|
+
const msg = JSON.stringify({
|
|
200
|
+
instanceId,
|
|
201
|
+
topic,
|
|
202
|
+
event: 'remove',
|
|
203
|
+
payload: { key }
|
|
204
|
+
});
|
|
205
|
+
redis.publish(channel, msg).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
update(ws, topic, data, platform) {
|
|
210
|
+
ensureSubscriber(platform);
|
|
211
|
+
|
|
212
|
+
const state = getWsState(ws);
|
|
213
|
+
state.topics.add(topic);
|
|
214
|
+
activeTopics.add(topic);
|
|
215
|
+
|
|
216
|
+
let topicMap = topics.get(topic);
|
|
217
|
+
if (!topicMap) {
|
|
218
|
+
topicMap = new Map();
|
|
219
|
+
topics.set(topic, topicMap);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let entry = topicMap.get(state.key);
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
|
|
225
|
+
if (!entry) {
|
|
226
|
+
entry = { user: state.user, data, lastBroadcast: 0, timer: null };
|
|
227
|
+
topicMap.set(state.key, entry);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Always store latest data
|
|
231
|
+
entry.data = data;
|
|
232
|
+
entry.user = state.user;
|
|
233
|
+
|
|
234
|
+
// Leading edge: broadcast immediately if throttle window passed
|
|
235
|
+
if (now - entry.lastBroadcast >= throttleMs) {
|
|
236
|
+
if (entry.timer) {
|
|
237
|
+
clearTimeout(entry.timer);
|
|
238
|
+
entry.timer = null;
|
|
239
|
+
}
|
|
240
|
+
entry.lastBroadcast = now;
|
|
241
|
+
broadcast(topic, state.key, state.user, data, platform);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Trailing edge: schedule a broadcast for the end of the window
|
|
246
|
+
if (!entry.timer) {
|
|
247
|
+
const key = state.key;
|
|
248
|
+
const user = state.user;
|
|
249
|
+
entry.timer = setTimeout(() => {
|
|
250
|
+
const e = topicMap.get(key);
|
|
251
|
+
if (e) {
|
|
252
|
+
e.lastBroadcast = Date.now();
|
|
253
|
+
e.timer = null;
|
|
254
|
+
broadcast(topic, key, user, e.data, platform);
|
|
255
|
+
}
|
|
256
|
+
}, throttleMs - (now - entry.lastBroadcast));
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async remove(ws, platform, topic) {
|
|
261
|
+
const state = wsState.get(ws);
|
|
262
|
+
if (!state) return;
|
|
263
|
+
|
|
264
|
+
if (topic !== undefined) {
|
|
265
|
+
// --- Per-topic remove ---
|
|
266
|
+
if (!state.topics.has(topic)) return;
|
|
267
|
+
state.topics.delete(topic);
|
|
268
|
+
|
|
269
|
+
const topicMap = topics.get(topic);
|
|
270
|
+
if (topicMap) {
|
|
271
|
+
const entry = topicMap.get(state.key);
|
|
272
|
+
if (entry) {
|
|
273
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
274
|
+
topicMap.delete(state.key);
|
|
275
|
+
if (topicMap.size === 0) {
|
|
276
|
+
topics.delete(topic);
|
|
277
|
+
activeTopics.delete(topic);
|
|
278
|
+
}
|
|
279
|
+
broadcastRemove(topic, state.key, platform);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (state.topics.size === 0) wsState.delete(ws);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- Remove from all topics ---
|
|
288
|
+
for (const topic of state.topics) {
|
|
289
|
+
const topicMap = topics.get(topic);
|
|
290
|
+
if (!topicMap) continue;
|
|
291
|
+
|
|
292
|
+
const entry = topicMap.get(state.key);
|
|
293
|
+
if (entry) {
|
|
294
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
295
|
+
topicMap.delete(state.key);
|
|
296
|
+
if (topicMap.size === 0) {
|
|
297
|
+
topics.delete(topic);
|
|
298
|
+
activeTopics.delete(topic);
|
|
299
|
+
}
|
|
300
|
+
broadcastRemove(topic, state.key, platform);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
wsState.delete(ws);
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async list(topic) {
|
|
308
|
+
const all = await redis.hgetall(hashKey(topic));
|
|
309
|
+
const result = [];
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const ttlMs = cursorTtl * 1000;
|
|
312
|
+
for (const [key, v] of Object.entries(all)) {
|
|
313
|
+
try {
|
|
314
|
+
const parsed = JSON.parse(v);
|
|
315
|
+
// Filter out stale entries from crashed instances
|
|
316
|
+
if (parsed.ts && (now - parsed.ts) > ttlMs) continue;
|
|
317
|
+
result.push({ key, user: parsed.user, data: parsed.data });
|
|
318
|
+
} catch {
|
|
319
|
+
// Corrupted entry, skip
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
async clear() {
|
|
326
|
+
// Clear all timers
|
|
327
|
+
for (const [, topicMap] of topics) {
|
|
328
|
+
for (const [, entry] of topicMap) {
|
|
329
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
topics.clear();
|
|
333
|
+
wsState.clear();
|
|
334
|
+
activeTopics.clear();
|
|
335
|
+
connCounter = 0;
|
|
336
|
+
|
|
337
|
+
// Clear Redis keys
|
|
338
|
+
const pattern = client.key('cursor:*');
|
|
339
|
+
let cursor = '0';
|
|
340
|
+
do {
|
|
341
|
+
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
342
|
+
cursor = nextCursor;
|
|
343
|
+
if (keys.length > 0) {
|
|
344
|
+
await redis.del(...keys);
|
|
345
|
+
}
|
|
346
|
+
} while (cursor !== '0');
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
destroy() {
|
|
350
|
+
// Clear all timers
|
|
351
|
+
clearInterval(cleanupTimer);
|
|
352
|
+
for (const [, topicMap] of topics) {
|
|
353
|
+
for (const [, entry] of topicMap) {
|
|
354
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (subscriber) {
|
|
358
|
+
subscriber.quit().catch(() => subscriber.disconnect());
|
|
359
|
+
subscriber = null;
|
|
360
|
+
}
|
|
361
|
+
subscriberReady = false;
|
|
362
|
+
activePlatform = null;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|