reflectdb 0.1.0 → 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/README.md +500 -40
- package/dist/cjs/client/index.cjs +32 -17
- package/dist/cjs/client/index.d.cts +18 -3
- package/dist/cjs/client/storage/indexeddb.d.cts +1 -1
- package/dist/cjs/core/index.cjs +28 -28
- package/dist/cjs/core/index.d.cts +21 -9
- package/dist/cjs/react/index.cjs +48 -33
- package/dist/cjs/react/index.d.cts +23 -5
- package/dist/cjs/server/drizzle.cjs +2 -2
- package/dist/cjs/server/drizzle.d.cts +1 -1
- package/dist/cjs/server/ephemeral/index.cjs +180 -0
- package/dist/cjs/server/ephemeral/index.d.cts +102 -0
- package/dist/cjs/server/ephemeral/redis.cjs +226 -0
- package/dist/cjs/server/ephemeral/redis.d.cts +136 -0
- package/dist/cjs/server/index.cjs +238 -87
- package/dist/cjs/server/index.d.cts +196 -5
- package/dist/cjs/svelte/index.cjs +34 -19
- package/dist/cjs/svelte/index.d.cts +18 -3
- package/dist/cjs/transport/bun-ws.cjs +9 -9
- package/dist/cjs/transport/bun-ws.d.cts +1 -1
- package/dist/cjs/transport/polling.cjs +11 -11
- package/dist/cjs/transport/polling.d.cts +1 -1
- package/dist/cjs/transport/sse.cjs +11 -11
- package/dist/cjs/transport/sse.d.cts +1 -1
- package/dist/cjs/transport/ws.cjs +11 -11
- package/dist/cjs/transport/ws.d.cts +1 -1
- package/dist/cjs/vanilla/index.cjs +34 -19
- package/dist/cjs/vanilla/index.d.cts +18 -3
- package/dist/client/index.d.ts +18 -3
- package/dist/client/index.js +7 -7
- package/dist/client/storage/indexeddb.d.ts +1 -1
- package/dist/client/storage/indexeddb.js +1 -1
- package/dist/core/index.d.ts +21 -9
- package/dist/core/index.js +20 -20
- package/dist/react/index.d.ts +23 -5
- package/dist/react/index.js +23 -23
- package/dist/server/drizzle.d.ts +1 -1
- package/dist/server/drizzle.js +3 -3
- package/dist/server/ephemeral/index.d.ts +102 -0
- package/dist/server/ephemeral/index.js +9 -0
- package/dist/server/ephemeral/redis.d.ts +136 -0
- package/dist/server/ephemeral/redis.js +186 -0
- package/dist/server/index.d.ts +196 -5
- package/dist/server/index.js +184 -163
- package/dist/shared/{esm-ytrd3hbq.js → esm-8qbr4y0d.js} +18 -3
- package/dist/shared/{esm-wkwx6bd9.js → esm-ck88h30s.js} +10 -10
- package/dist/shared/esm-g0marxk7.js +134 -0
- package/dist/svelte/index.d.ts +18 -3
- package/dist/svelte/index.js +9 -9
- package/dist/transport/bun-ws.d.ts +1 -1
- package/dist/transport/bun-ws.js +1 -1
- package/dist/transport/polling.d.ts +1 -1
- package/dist/transport/polling.js +3 -3
- package/dist/transport/sse.d.ts +1 -1
- package/dist/transport/sse.js +3 -3
- package/dist/transport/ws.d.ts +1 -1
- package/dist/transport/ws.js +3 -3
- package/dist/vanilla/index.d.ts +18 -3
- package/dist/vanilla/index.js +9 -9
- package/package.json +66 -44
- /package/dist/shared/{esm-b7xs9cde.js → esm-k7kedp3y.js} +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
var import_node_module = require("node:module");
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
function __accessProp(key) {
|
|
7
|
+
return this[key];
|
|
8
|
+
}
|
|
9
|
+
var __toCommonJS = (from) => {
|
|
10
|
+
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
11
|
+
if (entry)
|
|
12
|
+
return entry;
|
|
13
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (var key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(entry, key))
|
|
17
|
+
__defProp(entry, key, {
|
|
18
|
+
get: __accessProp.bind(from, key),
|
|
19
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
__moduleCache.set(from, entry);
|
|
23
|
+
return entry;
|
|
24
|
+
};
|
|
25
|
+
var __moduleCache;
|
|
26
|
+
var __returnValue = (v) => v;
|
|
27
|
+
function __exportSetter(name, newValue) {
|
|
28
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
29
|
+
}
|
|
30
|
+
var __export = (target, all) => {
|
|
31
|
+
for (var name in all)
|
|
32
|
+
__defProp(target, name, {
|
|
33
|
+
get: all[name],
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
set: __exportSetter.bind(all, name)
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
40
|
+
|
|
41
|
+
// src/server/ephemeral/index.ts
|
|
42
|
+
var exports_ephemeral = {};
|
|
43
|
+
__export(exports_ephemeral, {
|
|
44
|
+
EphemeralManager: () => EphemeralManager,
|
|
45
|
+
createMemoryEphemeral: () => createMemoryEphemeral
|
|
46
|
+
});
|
|
47
|
+
module.exports = __toCommonJS(exports_ephemeral);
|
|
48
|
+
|
|
49
|
+
// src/server/ephemeral/memory.ts
|
|
50
|
+
var DEFAULT_MAX_ENTRIES = 1e4;
|
|
51
|
+
function indexKey(room, key, clientId) {
|
|
52
|
+
return `${room}\x00${key}\x00${clientId}`;
|
|
53
|
+
}
|
|
54
|
+
function parseIndexKey(packed) {
|
|
55
|
+
const [room, key, clientId] = packed.split("\x00");
|
|
56
|
+
return { room, key, clientId };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class EphemeralManager {
|
|
60
|
+
store = new Map;
|
|
61
|
+
clientIndex = new Map;
|
|
62
|
+
entryCount = 0;
|
|
63
|
+
maxEntries;
|
|
64
|
+
constructor(maxEntries) {
|
|
65
|
+
this.maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
66
|
+
}
|
|
67
|
+
set(room, key, clientId, userId, data, ttlMs) {
|
|
68
|
+
const roomMap = this.store.get(room) ?? new Map;
|
|
69
|
+
const keyMap = roomMap.get(key) ?? new Map;
|
|
70
|
+
const existing = keyMap.get(clientId);
|
|
71
|
+
if (!existing && this.entryCount >= this.maxEntries) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const state = {
|
|
75
|
+
clientId,
|
|
76
|
+
userId,
|
|
77
|
+
key,
|
|
78
|
+
data,
|
|
79
|
+
updatedAt: Date.now(),
|
|
80
|
+
ttlMs
|
|
81
|
+
};
|
|
82
|
+
keyMap.set(clientId, state);
|
|
83
|
+
roomMap.set(key, keyMap);
|
|
84
|
+
this.store.set(room, roomMap);
|
|
85
|
+
if (!existing) {
|
|
86
|
+
this.entryCount++;
|
|
87
|
+
this.addToClientIndex(clientId, indexKey(room, key, clientId));
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
addToClientIndex(clientId, packed) {
|
|
92
|
+
const clientSet = this.clientIndex.get(clientId) ?? new Set;
|
|
93
|
+
clientSet.add(packed);
|
|
94
|
+
this.clientIndex.set(clientId, clientSet);
|
|
95
|
+
}
|
|
96
|
+
removeFromClientIndex(clientId, packed) {
|
|
97
|
+
const clientSet = this.clientIndex.get(clientId);
|
|
98
|
+
if (!clientSet)
|
|
99
|
+
return;
|
|
100
|
+
clientSet.delete(packed);
|
|
101
|
+
if (clientSet.size === 0)
|
|
102
|
+
this.clientIndex.delete(clientId);
|
|
103
|
+
}
|
|
104
|
+
get(room, key) {
|
|
105
|
+
const roomMap = this.store.get(room);
|
|
106
|
+
if (!roomMap)
|
|
107
|
+
return {};
|
|
108
|
+
const keyMap = roomMap.get(key);
|
|
109
|
+
if (!keyMap)
|
|
110
|
+
return {};
|
|
111
|
+
return Object.fromEntries(keyMap.entries());
|
|
112
|
+
}
|
|
113
|
+
getRoom(room) {
|
|
114
|
+
const roomMap = this.store.get(room);
|
|
115
|
+
if (!roomMap)
|
|
116
|
+
return {};
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [key, keyMap] of roomMap) {
|
|
119
|
+
out[key] = Object.fromEntries(keyMap.entries());
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
remove(room, key, clientId) {
|
|
124
|
+
const roomMap = this.store.get(room);
|
|
125
|
+
if (!roomMap)
|
|
126
|
+
return;
|
|
127
|
+
const keyMap = roomMap.get(key);
|
|
128
|
+
if (!keyMap)
|
|
129
|
+
return;
|
|
130
|
+
if (keyMap.has(clientId)) {
|
|
131
|
+
keyMap.delete(clientId);
|
|
132
|
+
this.entryCount--;
|
|
133
|
+
this.removeFromClientIndex(clientId, indexKey(room, key, clientId));
|
|
134
|
+
}
|
|
135
|
+
if (keyMap.size === 0) {
|
|
136
|
+
roomMap.delete(key);
|
|
137
|
+
}
|
|
138
|
+
if (roomMap.size === 0) {
|
|
139
|
+
this.store.delete(room);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
removeClient(clientId) {
|
|
143
|
+
const indices = this.clientIndex.get(clientId);
|
|
144
|
+
if (!indices)
|
|
145
|
+
return;
|
|
146
|
+
const entries = [...indices];
|
|
147
|
+
for (const packed of entries) {
|
|
148
|
+
const { room, key, clientId: owner } = parseIndexKey(packed);
|
|
149
|
+
this.remove(room, key, owner);
|
|
150
|
+
}
|
|
151
|
+
this.clientIndex.delete(clientId);
|
|
152
|
+
}
|
|
153
|
+
cleanupExpired() {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const toRemove = [];
|
|
156
|
+
for (const [room, roomMap] of this.store) {
|
|
157
|
+
for (const [key, keyMap] of roomMap) {
|
|
158
|
+
for (const [clientId, state] of keyMap) {
|
|
159
|
+
if (state.ttlMs && now - state.updatedAt > state.ttlMs) {
|
|
160
|
+
toRemove.push({ room, key, clientId });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const { room, key, clientId } of toRemove) {
|
|
166
|
+
this.remove(room, key, clientId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
size() {
|
|
170
|
+
return this.entryCount;
|
|
171
|
+
}
|
|
172
|
+
destroy() {
|
|
173
|
+
this.store.clear();
|
|
174
|
+
this.clientIndex.clear();
|
|
175
|
+
this.entryCount = 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function createMemoryEphemeral(maxEntries) {
|
|
179
|
+
return new EphemeralManager(maxEntries);
|
|
180
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ephemeral (presence, cursors, typing) storage and fan-out seam.
|
|
3
|
+
*
|
|
4
|
+
* The default adapter keeps state in the server process, which is correct for a
|
|
5
|
+
* single node and invisible across a fleet: two clients on different instances
|
|
6
|
+
* never see each other. An adapter backed by shared infrastructure (Redis, or a
|
|
7
|
+
* hosted service) fixes both halves — the shared store answers "who is here"
|
|
8
|
+
* for a client that just joined, and the bus carries each event to the peers
|
|
9
|
+
* holding the other sockets.
|
|
10
|
+
*/
|
|
11
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
12
|
+
interface EphemeralState {
|
|
13
|
+
clientId: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
key: string;
|
|
16
|
+
data: Record<string, unknown>;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
ttlMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** One fan-out target: subscribers of `query`, narrowed to `room` when set. */
|
|
21
|
+
interface EphemeralTarget {
|
|
22
|
+
query: string;
|
|
23
|
+
room: string | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* An ephemeral event as it crosses the bus between server instances.
|
|
27
|
+
*
|
|
28
|
+
* Recipients are resolved on the origin instance and carried here as `targets`,
|
|
29
|
+
* because the receiving instance holds no session for the sender and so cannot
|
|
30
|
+
* re-derive them. `serverId` lets an instance drop its own echo on buses that
|
|
31
|
+
* deliver published messages back to the publisher.
|
|
32
|
+
*/
|
|
33
|
+
interface EphemeralBroadcast {
|
|
34
|
+
serverId: string;
|
|
35
|
+
key: string;
|
|
36
|
+
clientId: string;
|
|
37
|
+
userId: string;
|
|
38
|
+
data: Record<string, unknown>;
|
|
39
|
+
ttlMs?: number;
|
|
40
|
+
targets: EphemeralTarget[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Backing store and fan-out bus for ephemeral state.
|
|
44
|
+
*
|
|
45
|
+
* `publish`/`subscribe` are optional: an adapter that omits them is a
|
|
46
|
+
* single-process store, and the handler still fans out to its own sockets.
|
|
47
|
+
*/
|
|
48
|
+
interface EphemeralAdapter {
|
|
49
|
+
/**
|
|
50
|
+
* Record one entry, keyed by `clientId`.
|
|
51
|
+
*
|
|
52
|
+
* Peer identity is the connection, not the account: two tabs from one login
|
|
53
|
+
* are two cursors, and the client bindings key peers the same way. `userId`
|
|
54
|
+
* rides along for display and authorization, not as the entry's identity.
|
|
55
|
+
*
|
|
56
|
+
* Returns false when the adapter is at capacity, which the handler surfaces
|
|
57
|
+
* as `ephemeral_full` rather than evicting silently.
|
|
58
|
+
*/
|
|
59
|
+
set(room: string, key: string, clientId: string, userId: string, data: Record<string, unknown>, ttlMs?: number): MaybePromise<boolean>;
|
|
60
|
+
/** Live entries for one channel, keyed by clientId. */
|
|
61
|
+
get(room: string, key: string): MaybePromise<Record<string, EphemeralState>>;
|
|
62
|
+
/**
|
|
63
|
+
* Live entries for every channel in a room, keyed by channel key then
|
|
64
|
+
* clientId. Backs the snapshot a client receives when it joins.
|
|
65
|
+
*/
|
|
66
|
+
getRoom(room: string): MaybePromise<Record<string, Record<string, EphemeralState>>>;
|
|
67
|
+
remove(room: string, key: string, clientId: string): MaybePromise<void>;
|
|
68
|
+
/** Drop everything a disconnecting client published. */
|
|
69
|
+
removeClient(clientId: string): MaybePromise<void>;
|
|
70
|
+
/** Sweep entries past their TTL. Called on a timer by the handler. */
|
|
71
|
+
cleanupExpired(): MaybePromise<void>;
|
|
72
|
+
/** Current entry count, for the capacity gate and `ephemeral_full`. */
|
|
73
|
+
size(): MaybePromise<number>;
|
|
74
|
+
destroy(): MaybePromise<void>;
|
|
75
|
+
/** Hand an event to peer instances. Absent on single-process adapters. */
|
|
76
|
+
publish?(event: EphemeralBroadcast): MaybePromise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Register the handler's delivery callback for events published by peers.
|
|
79
|
+
* Called once during wiring. Absent on single-process adapters.
|
|
80
|
+
*/
|
|
81
|
+
subscribe?(onEvent: (event: EphemeralBroadcast) => void): MaybePromise<void>;
|
|
82
|
+
}
|
|
83
|
+
declare class EphemeralManager implements EphemeralAdapter {
|
|
84
|
+
private store;
|
|
85
|
+
private clientIndex;
|
|
86
|
+
private entryCount;
|
|
87
|
+
private maxEntries;
|
|
88
|
+
constructor(maxEntries?: number);
|
|
89
|
+
set(room: string, key: string, clientId: string, userId: string, data: Record<string, unknown>, ttlMs?: number): boolean;
|
|
90
|
+
private addToClientIndex;
|
|
91
|
+
private removeFromClientIndex;
|
|
92
|
+
get(room: string, key: string): Record<string, EphemeralState>;
|
|
93
|
+
getRoom(room: string): Record<string, Record<string, EphemeralState>>;
|
|
94
|
+
remove(room: string, key: string, clientId: string): void;
|
|
95
|
+
removeClient(clientId: string): void;
|
|
96
|
+
cleanupExpired(): void;
|
|
97
|
+
size(): number;
|
|
98
|
+
destroy(): void;
|
|
99
|
+
}
|
|
100
|
+
/** Convenience factory mirroring the other adapters. */
|
|
101
|
+
declare function createMemoryEphemeral(maxEntries?: number): EphemeralManager;
|
|
102
|
+
export { EphemeralAdapter, EphemeralBroadcast, EphemeralManager, EphemeralState, EphemeralTarget, MaybePromise, createMemoryEphemeral };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
var import_node_module = require("node:module");
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
function __accessProp(key) {
|
|
7
|
+
return this[key];
|
|
8
|
+
}
|
|
9
|
+
var __toCommonJS = (from) => {
|
|
10
|
+
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
11
|
+
if (entry)
|
|
12
|
+
return entry;
|
|
13
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (var key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(entry, key))
|
|
17
|
+
__defProp(entry, key, {
|
|
18
|
+
get: __accessProp.bind(from, key),
|
|
19
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
__moduleCache.set(from, entry);
|
|
23
|
+
return entry;
|
|
24
|
+
};
|
|
25
|
+
var __moduleCache;
|
|
26
|
+
var __returnValue = (v) => v;
|
|
27
|
+
function __exportSetter(name, newValue) {
|
|
28
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
29
|
+
}
|
|
30
|
+
var __export = (target, all) => {
|
|
31
|
+
for (var name in all)
|
|
32
|
+
__defProp(target, name, {
|
|
33
|
+
get: all[name],
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
set: __exportSetter.bind(all, name)
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
40
|
+
|
|
41
|
+
// src/server/ephemeral/redis.ts
|
|
42
|
+
var exports_redis = {};
|
|
43
|
+
__export(exports_redis, {
|
|
44
|
+
createRedisEphemeral: () => createRedisEphemeral
|
|
45
|
+
});
|
|
46
|
+
module.exports = __toCommonJS(exports_redis);
|
|
47
|
+
var DEFAULT_PREFIX = "reflectdb:eph";
|
|
48
|
+
var DEFAULT_MAX_ENTRIES = 1e5;
|
|
49
|
+
var DEFAULT_HASH_TTL_SECONDS = 24 * 60 * 60;
|
|
50
|
+
var SWEEP_BATCH = 1000;
|
|
51
|
+
function packed(room, key, clientId) {
|
|
52
|
+
return `${room}\x00${key}\x00${clientId}`;
|
|
53
|
+
}
|
|
54
|
+
function unpack(value) {
|
|
55
|
+
const [room, key, clientId] = value.split("\x00");
|
|
56
|
+
return { room, key, clientId };
|
|
57
|
+
}
|
|
58
|
+
function hashEntries(reply) {
|
|
59
|
+
if (Array.isArray(reply)) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let i = 0;i + 1 < reply.length; i += 2) {
|
|
62
|
+
out.push([String(reply[i]), String(reply[i + 1])]);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
if (reply && typeof reply === "object") {
|
|
67
|
+
return Object.entries(reply).map(([k, v]) => [k, String(v)]);
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
function toStringArray(reply) {
|
|
72
|
+
return Array.isArray(reply) ? reply.map((v) => String(v)) : [];
|
|
73
|
+
}
|
|
74
|
+
var SET_SCRIPT = `
|
|
75
|
+
local isNew = 0
|
|
76
|
+
if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then
|
|
77
|
+
local count = tonumber(redis.call('GET', KEYS[5]) or '0')
|
|
78
|
+
if count >= tonumber(ARGV[6]) then return 0 end
|
|
79
|
+
isNew = 1
|
|
80
|
+
end
|
|
81
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
82
|
+
redis.call('EXPIRE', KEYS[1], ARGV[7])
|
|
83
|
+
redis.call('SADD', KEYS[2], ARGV[4])
|
|
84
|
+
redis.call('EXPIRE', KEYS[2], ARGV[7])
|
|
85
|
+
redis.call('SADD', KEYS[3], ARGV[3])
|
|
86
|
+
redis.call('EXPIRE', KEYS[3], ARGV[7])
|
|
87
|
+
if ARGV[5] ~= '' then
|
|
88
|
+
redis.call('ZADD', KEYS[4], ARGV[5], ARGV[3])
|
|
89
|
+
else
|
|
90
|
+
redis.call('ZREM', KEYS[4], ARGV[3])
|
|
91
|
+
end
|
|
92
|
+
if isNew == 1 then redis.call('INCR', KEYS[5]) end
|
|
93
|
+
return 1
|
|
94
|
+
`;
|
|
95
|
+
var REMOVE_SCRIPT = `
|
|
96
|
+
if redis.call('HDEL', KEYS[1], ARGV[1]) == 0 then return 0 end
|
|
97
|
+
redis.call('ZREM', KEYS[3], ARGV[2])
|
|
98
|
+
redis.call('SREM', KEYS[5], ARGV[2])
|
|
99
|
+
if redis.call('HLEN', KEYS[1]) == 0 then
|
|
100
|
+
redis.call('SREM', KEYS[2], ARGV[3])
|
|
101
|
+
end
|
|
102
|
+
local count = tonumber(redis.call('GET', KEYS[4]) or '0')
|
|
103
|
+
if count > 0 then redis.call('DECR', KEYS[4]) end
|
|
104
|
+
return 1
|
|
105
|
+
`;
|
|
106
|
+
function createRedisEphemeral(config) {
|
|
107
|
+
const client = config.client;
|
|
108
|
+
const p = config.prefix ?? DEFAULT_PREFIX;
|
|
109
|
+
const maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
110
|
+
const hashTtl = config.hashTtlSeconds ?? DEFAULT_HASH_TTL_SECONDS;
|
|
111
|
+
const stateKey = (room, key) => `${p}:s:${room}:${key}`;
|
|
112
|
+
const roomKeysKey = (room) => `${p}:rk:${room}`;
|
|
113
|
+
const clientKey = (clientId) => `${p}:c:${clientId}`;
|
|
114
|
+
const expKey = `${p}:exp`;
|
|
115
|
+
const countKey = `${p}:n`;
|
|
116
|
+
const busChannel = `${p}:bus`;
|
|
117
|
+
let readyPromise = null;
|
|
118
|
+
let onEventCallback = null;
|
|
119
|
+
function parseState(raw) {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(raw);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function isLive(state, now) {
|
|
127
|
+
return !state.ttlMs || now - state.updatedAt <= state.ttlMs;
|
|
128
|
+
}
|
|
129
|
+
async function removeEntry(room, key, clientId) {
|
|
130
|
+
await client.call("EVAL", REMOVE_SCRIPT, 5, stateKey(room, key), roomKeysKey(room), expKey, countKey, clientKey(clientId), clientId, packed(room, key, clientId), key);
|
|
131
|
+
}
|
|
132
|
+
async function ready() {
|
|
133
|
+
if (readyPromise)
|
|
134
|
+
return readyPromise;
|
|
135
|
+
const subscriber = config.subscriber;
|
|
136
|
+
if (!subscriber)
|
|
137
|
+
return;
|
|
138
|
+
readyPromise = (async () => {
|
|
139
|
+
await subscriber.subscribe(busChannel, (payload) => {
|
|
140
|
+
if (!onEventCallback)
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
onEventCallback(JSON.parse(payload));
|
|
144
|
+
} catch {}
|
|
145
|
+
});
|
|
146
|
+
})();
|
|
147
|
+
return readyPromise;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
ready,
|
|
151
|
+
async set(room, key, clientId, userId, data, ttlMs) {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const state = {
|
|
154
|
+
clientId,
|
|
155
|
+
userId,
|
|
156
|
+
key,
|
|
157
|
+
data,
|
|
158
|
+
updatedAt: now,
|
|
159
|
+
ttlMs
|
|
160
|
+
};
|
|
161
|
+
const entry = packed(room, key, clientId);
|
|
162
|
+
const result = await client.call("EVAL", SET_SCRIPT, 5, stateKey(room, key), roomKeysKey(room), clientKey(clientId), expKey, countKey, clientId, JSON.stringify(state), entry, key, ttlMs ? String(now + ttlMs) : "", String(maxEntries), String(hashTtl));
|
|
163
|
+
return Number(result) === 1;
|
|
164
|
+
},
|
|
165
|
+
async get(room, key) {
|
|
166
|
+
const reply = await client.call("HGETALL", stateKey(room, key));
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
const out = {};
|
|
169
|
+
for (const [entryClientId, raw] of hashEntries(reply)) {
|
|
170
|
+
const state = parseState(raw);
|
|
171
|
+
if (state && isLive(state, now))
|
|
172
|
+
out[entryClientId] = state;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
},
|
|
176
|
+
async getRoom(room) {
|
|
177
|
+
const keys = toStringArray(await client.call("SMEMBERS", roomKeysKey(room)));
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const out = {};
|
|
180
|
+
for (const key of keys) {
|
|
181
|
+
const reply = await client.call("HGETALL", stateKey(room, key));
|
|
182
|
+
const channel = {};
|
|
183
|
+
for (const [entryClientId, raw] of hashEntries(reply)) {
|
|
184
|
+
const state = parseState(raw);
|
|
185
|
+
if (state && isLive(state, now))
|
|
186
|
+
channel[entryClientId] = state;
|
|
187
|
+
}
|
|
188
|
+
if (Object.keys(channel).length > 0)
|
|
189
|
+
out[key] = channel;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
},
|
|
193
|
+
async remove(room, key, clientId) {
|
|
194
|
+
await removeEntry(room, key, clientId);
|
|
195
|
+
},
|
|
196
|
+
async removeClient(clientId) {
|
|
197
|
+
const entries = toStringArray(await client.call("SMEMBERS", clientKey(clientId)));
|
|
198
|
+
for (const value of entries) {
|
|
199
|
+
const { room, key, clientId: owner } = unpack(value);
|
|
200
|
+
await removeEntry(room, key, owner);
|
|
201
|
+
}
|
|
202
|
+
await client.call("DEL", clientKey(clientId));
|
|
203
|
+
},
|
|
204
|
+
async cleanupExpired() {
|
|
205
|
+
const due = toStringArray(await client.call("ZRANGEBYSCORE", expKey, "-inf", String(Date.now()), "LIMIT", 0, SWEEP_BATCH));
|
|
206
|
+
for (const value of due) {
|
|
207
|
+
const { room, key, clientId } = unpack(value);
|
|
208
|
+
await removeEntry(room, key, clientId);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async size() {
|
|
212
|
+
const reply = await client.call("GET", countKey);
|
|
213
|
+
return reply == null ? 0 : Number(reply);
|
|
214
|
+
},
|
|
215
|
+
async destroy() {
|
|
216
|
+
onEventCallback = null;
|
|
217
|
+
},
|
|
218
|
+
async publish(event) {
|
|
219
|
+
await client.call("PUBLISH", busChannel, JSON.stringify(event));
|
|
220
|
+
},
|
|
221
|
+
async subscribe(onEvent) {
|
|
222
|
+
onEventCallback = onEvent;
|
|
223
|
+
await ready();
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ephemeral (presence, cursors, typing) storage and fan-out seam.
|
|
3
|
+
*
|
|
4
|
+
* The default adapter keeps state in the server process, which is correct for a
|
|
5
|
+
* single node and invisible across a fleet: two clients on different instances
|
|
6
|
+
* never see each other. An adapter backed by shared infrastructure (Redis, or a
|
|
7
|
+
* hosted service) fixes both halves — the shared store answers "who is here"
|
|
8
|
+
* for a client that just joined, and the bus carries each event to the peers
|
|
9
|
+
* holding the other sockets.
|
|
10
|
+
*/
|
|
11
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
12
|
+
interface EphemeralState {
|
|
13
|
+
clientId: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
key: string;
|
|
16
|
+
data: Record<string, unknown>;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
ttlMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** One fan-out target: subscribers of `query`, narrowed to `room` when set. */
|
|
21
|
+
interface EphemeralTarget {
|
|
22
|
+
query: string;
|
|
23
|
+
room: string | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* An ephemeral event as it crosses the bus between server instances.
|
|
27
|
+
*
|
|
28
|
+
* Recipients are resolved on the origin instance and carried here as `targets`,
|
|
29
|
+
* because the receiving instance holds no session for the sender and so cannot
|
|
30
|
+
* re-derive them. `serverId` lets an instance drop its own echo on buses that
|
|
31
|
+
* deliver published messages back to the publisher.
|
|
32
|
+
*/
|
|
33
|
+
interface EphemeralBroadcast {
|
|
34
|
+
serverId: string;
|
|
35
|
+
key: string;
|
|
36
|
+
clientId: string;
|
|
37
|
+
userId: string;
|
|
38
|
+
data: Record<string, unknown>;
|
|
39
|
+
ttlMs?: number;
|
|
40
|
+
targets: EphemeralTarget[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Backing store and fan-out bus for ephemeral state.
|
|
44
|
+
*
|
|
45
|
+
* `publish`/`subscribe` are optional: an adapter that omits them is a
|
|
46
|
+
* single-process store, and the handler still fans out to its own sockets.
|
|
47
|
+
*/
|
|
48
|
+
interface EphemeralAdapter {
|
|
49
|
+
/**
|
|
50
|
+
* Record one entry, keyed by `clientId`.
|
|
51
|
+
*
|
|
52
|
+
* Peer identity is the connection, not the account: two tabs from one login
|
|
53
|
+
* are two cursors, and the client bindings key peers the same way. `userId`
|
|
54
|
+
* rides along for display and authorization, not as the entry's identity.
|
|
55
|
+
*
|
|
56
|
+
* Returns false when the adapter is at capacity, which the handler surfaces
|
|
57
|
+
* as `ephemeral_full` rather than evicting silently.
|
|
58
|
+
*/
|
|
59
|
+
set(room: string, key: string, clientId: string, userId: string, data: Record<string, unknown>, ttlMs?: number): MaybePromise<boolean>;
|
|
60
|
+
/** Live entries for one channel, keyed by clientId. */
|
|
61
|
+
get(room: string, key: string): MaybePromise<Record<string, EphemeralState>>;
|
|
62
|
+
/**
|
|
63
|
+
* Live entries for every channel in a room, keyed by channel key then
|
|
64
|
+
* clientId. Backs the snapshot a client receives when it joins.
|
|
65
|
+
*/
|
|
66
|
+
getRoom(room: string): MaybePromise<Record<string, Record<string, EphemeralState>>>;
|
|
67
|
+
remove(room: string, key: string, clientId: string): MaybePromise<void>;
|
|
68
|
+
/** Drop everything a disconnecting client published. */
|
|
69
|
+
removeClient(clientId: string): MaybePromise<void>;
|
|
70
|
+
/** Sweep entries past their TTL. Called on a timer by the handler. */
|
|
71
|
+
cleanupExpired(): MaybePromise<void>;
|
|
72
|
+
/** Current entry count, for the capacity gate and `ephemeral_full`. */
|
|
73
|
+
size(): MaybePromise<number>;
|
|
74
|
+
destroy(): MaybePromise<void>;
|
|
75
|
+
/** Hand an event to peer instances. Absent on single-process adapters. */
|
|
76
|
+
publish?(event: EphemeralBroadcast): MaybePromise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Register the handler's delivery callback for events published by peers.
|
|
79
|
+
* Called once during wiring. Absent on single-process adapters.
|
|
80
|
+
*/
|
|
81
|
+
subscribe?(onEvent: (event: EphemeralBroadcast) => void): MaybePromise<void>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Minimal Redis client interface — one raw-command entrypoint.
|
|
85
|
+
*
|
|
86
|
+
* Compatible with ioredis (`client.call`) directly. For node-redis, wrap it:
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* const shim = { call: (cmd, ...args) => client.sendCommand([cmd, ...args.map(String)]) };
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
interface RedisLike {
|
|
93
|
+
call(command: string, ...args: (string | number)[]): Promise<unknown>;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Pub/sub seam — one function, because no two Redis clients agree on the shape
|
|
97
|
+
* of theirs (argument order, `on("message")` vs a per-channel listener). Wire
|
|
98
|
+
* yours in a two-line shim rather than have this module guess:
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* // ioredis (separate connection — subscribe mode blocks other commands)
|
|
102
|
+
* { subscribe: (ch, onMessage) => {
|
|
103
|
+
* sub.on("message", (c, m) => { if (c === ch) onMessage(m); });
|
|
104
|
+
* return sub.subscribe(ch);
|
|
105
|
+
* } }
|
|
106
|
+
*
|
|
107
|
+
* // Bun
|
|
108
|
+
* { subscribe: (ch, onMessage) => sub.subscribe(ch, (msg) => onMessage(msg)) }
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
interface RedisSubscriberLike {
|
|
112
|
+
subscribe(channel: string, onMessage: (payload: string) => void): Promise<unknown> | unknown;
|
|
113
|
+
}
|
|
114
|
+
interface RedisEphemeralConfig {
|
|
115
|
+
/** Command connection. */
|
|
116
|
+
client: RedisLike;
|
|
117
|
+
/**
|
|
118
|
+
* Separate connection used for pub/sub. Omit to run shared-state-only —
|
|
119
|
+
* peers then see each other's presence on join and on sweep, but not live.
|
|
120
|
+
*/
|
|
121
|
+
subscriber?: RedisSubscriberLike;
|
|
122
|
+
/** Key prefix. Default: "reflectdb:eph" */
|
|
123
|
+
prefix?: string;
|
|
124
|
+
/** Global entry ceiling across the fleet. Default: 100_000 */
|
|
125
|
+
maxEntries?: number;
|
|
126
|
+
/**
|
|
127
|
+
* Safety-net expiry on state hashes, in seconds. Guards against a crashed
|
|
128
|
+
* instance leaving entries behind when its clients never disconnect
|
|
129
|
+
* cleanly. Default: 86400 (24h), matching the handler's TTL clamp.
|
|
130
|
+
*/
|
|
131
|
+
hashTtlSeconds?: number;
|
|
132
|
+
}
|
|
133
|
+
declare function createRedisEphemeral(config: RedisEphemeralConfig): EphemeralAdapter & {
|
|
134
|
+
ready(): Promise<void>;
|
|
135
|
+
};
|
|
136
|
+
export { RedisEphemeralConfig, RedisLike, RedisSubscriberLike, createRedisEphemeral };
|