@shadow-js/realtime 0.0.0-stage → 1.0.0
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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/dev/adapter.d.ts +75 -0
- package/dist/dev/client/core.d.ts +24 -0
- package/dist/dev/client/core.js +845 -0
- package/dist/dev/client/core.js.map +1 -0
- package/dist/dev/client/index.d.ts +5 -0
- package/dist/dev/client/index.js +3 -0
- package/dist/dev/client/presence.js +23 -0
- package/dist/dev/client/presence.js.map +1 -0
- package/dist/dev/client/sse.js +76 -0
- package/dist/dev/client/sse.js.map +1 -0
- package/dist/dev/client/types.d.ts +256 -0
- package/dist/dev/client/util.js +30 -0
- package/dist/dev/client/util.js.map +1 -0
- package/dist/dev/client/ws.js +39 -0
- package/dist/dev/client/ws.js.map +1 -0
- package/dist/dev/cloudflare/adapter.d.ts +80 -0
- package/dist/dev/cloudflare/adapter.js +347 -0
- package/dist/dev/cloudflare/adapter.js.map +1 -0
- package/dist/dev/cloudflare/core.js +701 -0
- package/dist/dev/cloudflare/core.js.map +1 -0
- package/dist/dev/cloudflare/link.js +397 -0
- package/dist/dev/cloudflare/link.js.map +1 -0
- package/dist/dev/cloudflare/object.d.ts +82 -0
- package/dist/dev/cloudflare/object.js +214 -0
- package/dist/dev/cloudflare/object.js.map +1 -0
- package/dist/dev/cloudflare/types.d.ts +100 -0
- package/dist/dev/cloudflare/wire.js +284 -0
- package/dist/dev/cloudflare/wire.js.map +1 -0
- package/dist/dev/cloudflare.d.ts +4 -0
- package/dist/dev/cloudflare.js +3 -0
- package/dist/dev/errors.d.ts +25 -0
- package/dist/dev/errors.js +24 -0
- package/dist/dev/errors.js.map +1 -0
- package/dist/dev/hooks.d.ts +110 -0
- package/dist/dev/hooks.js +162 -0
- package/dist/dev/hooks.js.map +1 -0
- package/dist/dev/index.d.ts +4 -0
- package/dist/dev/index.js +3 -0
- package/dist/dev/memory.d.ts +33 -0
- package/dist/dev/memory.js +255 -0
- package/dist/dev/memory.js.map +1 -0
- package/dist/dev/protocol.d.ts +211 -0
- package/dist/dev/protocol.js +400 -0
- package/dist/dev/protocol.js.map +1 -0
- package/dist/dev/redis/client.d.ts +139 -0
- package/dist/dev/redis/client.js +146 -0
- package/dist/dev/redis/client.js.map +1 -0
- package/dist/dev/redis/scripts.js +275 -0
- package/dist/dev/redis/scripts.js.map +1 -0
- package/dist/dev/redis/topics.d.ts +8 -0
- package/dist/dev/redis/topics.js +377 -0
- package/dist/dev/redis/topics.js.map +1 -0
- package/dist/dev/redis.d.ts +68 -0
- package/dist/dev/redis.js +422 -0
- package/dist/dev/redis.js.map +1 -0
- package/dist/dev/server/http.d.ts +48 -0
- package/dist/dev/server/http.js +286 -0
- package/dist/dev/server/http.js.map +1 -0
- package/dist/dev/server/hub.d.ts +114 -0
- package/dist/dev/server/hub.js +827 -0
- package/dist/dev/server/hub.js.map +1 -0
- package/dist/dev/server/index.d.ts +47 -0
- package/dist/dev/server/index.js +41 -0
- package/dist/dev/server/index.js.map +1 -0
- package/dist/dev/server/limits.d.ts +54 -0
- package/dist/dev/server/limits.js +85 -0
- package/dist/dev/server/limits.js.map +1 -0
- package/dist/dev/server/rules.d.ts +43 -0
- package/dist/dev/server/rules.js +72 -0
- package/dist/dev/server/rules.js.map +1 -0
- package/dist/dev/testing.d.ts +115 -0
- package/dist/dev/testing.js +427 -0
- package/dist/dev/testing.js.map +1 -0
- package/dist/prod/client/core.js +2 -0
- package/dist/prod/client/core.js.map +1 -0
- package/dist/prod/client/index.js +1 -0
- package/dist/prod/client/presence.js +2 -0
- package/dist/prod/client/presence.js.map +1 -0
- package/dist/prod/client/sse.js +5 -0
- package/dist/prod/client/sse.js.map +1 -0
- package/dist/prod/client/util.js +2 -0
- package/dist/prod/client/util.js.map +1 -0
- package/dist/prod/client/ws.js +2 -0
- package/dist/prod/client/ws.js.map +1 -0
- package/dist/prod/cloudflare/adapter.js +3 -0
- package/dist/prod/cloudflare/adapter.js.map +1 -0
- package/dist/prod/cloudflare/core.js +2 -0
- package/dist/prod/cloudflare/core.js.map +1 -0
- package/dist/prod/cloudflare/link.js +2 -0
- package/dist/prod/cloudflare/link.js.map +1 -0
- package/dist/prod/cloudflare/object.js +2 -0
- package/dist/prod/cloudflare/object.js.map +1 -0
- package/dist/prod/cloudflare/wire.js +2 -0
- package/dist/prod/cloudflare/wire.js.map +1 -0
- package/dist/prod/cloudflare.js +1 -0
- package/dist/prod/errors.js +2 -0
- package/dist/prod/errors.js.map +1 -0
- package/dist/prod/hooks.js +2 -0
- package/dist/prod/hooks.js.map +1 -0
- package/dist/prod/index.js +1 -0
- package/dist/prod/memory.js +2 -0
- package/dist/prod/memory.js.map +1 -0
- package/dist/prod/protocol.js +2 -0
- package/dist/prod/protocol.js.map +1 -0
- package/dist/prod/redis/client.js +2 -0
- package/dist/prod/redis/client.js.map +1 -0
- package/dist/prod/redis/scripts.js +157 -0
- package/dist/prod/redis/scripts.js.map +1 -0
- package/dist/prod/redis/topics.js +2 -0
- package/dist/prod/redis/topics.js.map +1 -0
- package/dist/prod/redis.js +2 -0
- package/dist/prod/redis.js.map +1 -0
- package/dist/prod/server/http.js +2 -0
- package/dist/prod/server/http.js.map +1 -0
- package/dist/prod/server/hub.js +2 -0
- package/dist/prod/server/hub.js.map +1 -0
- package/dist/prod/server/index.js +2 -0
- package/dist/prod/server/index.js.map +1 -0
- package/dist/prod/server/limits.js +2 -0
- package/dist/prod/server/limits.js.map +1 -0
- package/dist/prod/server/rules.js +2 -0
- package/dist/prod/server/rules.js.map +1 -0
- package/dist/prod/testing.js +2 -0
- package/dist/prod/testing.js.map +1 -0
- package/package.json +105 -4
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { RealtimeError } from "../errors.js";
|
|
2
|
+
import { historyKey, seqOfKey } from "./wire.js";
|
|
3
|
+
//#region src/cloudflare/core.ts
|
|
4
|
+
const META = "meta";
|
|
5
|
+
const MESSAGES = "h:";
|
|
6
|
+
const MEMBERS = "p:";
|
|
7
|
+
const KEYS = "k:";
|
|
8
|
+
const KEY_INDEX = "x:";
|
|
9
|
+
/** `x:` + 16 digits + `:`; what follows is the idempotency key. */
|
|
10
|
+
const KEY_INDEX_PREFIX = 19;
|
|
11
|
+
/** The platform accepts at most 128 keys in one `delete`, `put` or `get`. */
|
|
12
|
+
const BATCH = 128;
|
|
13
|
+
/** The most messages one operation deletes; a larger backlog is finished by the next operation or alarm. */
|
|
14
|
+
const TRIM_MAX = 1024;
|
|
15
|
+
/** Waiting this long after the first idempotency key expires lets one alarm sweep many. */
|
|
16
|
+
const SWEEP_SLACK_MS = 5e3;
|
|
17
|
+
/** The same for retained messages. */
|
|
18
|
+
const HISTORY_SLACK_MS = 1e3;
|
|
19
|
+
const PAGE_MESSAGES = 512;
|
|
20
|
+
const PAGE_BYTES = 4194304;
|
|
21
|
+
const REPLAY_MESSAGES = 1e4;
|
|
22
|
+
const REPLAY_BYTES = 8388608;
|
|
23
|
+
function pad(value) {
|
|
24
|
+
return String(value).padStart(16, "0");
|
|
25
|
+
}
|
|
26
|
+
function chunks(items) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (let at = 0; at < items.length; at += BATCH) out.push(items.slice(at, at + BATCH));
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function sizeOf(message) {
|
|
32
|
+
return message.data.length + message.event.length + 96;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The single writer of one channel.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
var ChannelCore = class {
|
|
39
|
+
#storage;
|
|
40
|
+
#now;
|
|
41
|
+
#maxMembers;
|
|
42
|
+
#sockets = /* @__PURE__ */ new Set();
|
|
43
|
+
#tail = Promise.resolve();
|
|
44
|
+
#meta;
|
|
45
|
+
#members;
|
|
46
|
+
/** The alarm time the storage holds: `undefined` unknown, `null` none. */
|
|
47
|
+
#alarmAt;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.#storage = options.storage;
|
|
50
|
+
this.#now = options.now ?? Date.now;
|
|
51
|
+
this.#maxMembers = options.maxMembers ?? 1e4;
|
|
52
|
+
}
|
|
53
|
+
/** Registers sockets that were open before this instance was created (the runtime kept them while the object slept). */
|
|
54
|
+
adopt(sockets) {
|
|
55
|
+
for (const socket of sockets) this.#sockets.add(socket);
|
|
56
|
+
}
|
|
57
|
+
/** Forgets a socket that closed. */
|
|
58
|
+
detach(socket) {
|
|
59
|
+
this.#sockets.delete(socket);
|
|
60
|
+
}
|
|
61
|
+
/** The number of sockets attached. */
|
|
62
|
+
get sockets() {
|
|
63
|
+
return this.#sockets.size;
|
|
64
|
+
}
|
|
65
|
+
/** Runs `task` after every earlier task finished. A failure drops the caches, so the next task reads the storage afresh. */
|
|
66
|
+
#run(task) {
|
|
67
|
+
const result = this.#tail.then(task).catch((error) => {
|
|
68
|
+
if (!(error instanceof RealtimeError)) {
|
|
69
|
+
this.#meta = void 0;
|
|
70
|
+
this.#members = void 0;
|
|
71
|
+
this.#alarmAt = void 0;
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
});
|
|
75
|
+
this.#tail = result.then(() => void 0, () => void 0);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
async #metaFor(channel) {
|
|
79
|
+
const meta = await this.#storedMeta();
|
|
80
|
+
if (meta !== void 0) {
|
|
81
|
+
if (meta.channel !== channel) throw new RealtimeError("conflict", "the object belongs to another channel");
|
|
82
|
+
return meta;
|
|
83
|
+
}
|
|
84
|
+
const fresh = {
|
|
85
|
+
v: 1,
|
|
86
|
+
channel,
|
|
87
|
+
head: 0,
|
|
88
|
+
oldest: 0,
|
|
89
|
+
oldestAt: 0,
|
|
90
|
+
keep: 0,
|
|
91
|
+
ttlMs: 0,
|
|
92
|
+
keysDue: 0,
|
|
93
|
+
presenceDue: 0,
|
|
94
|
+
graceMs: 0,
|
|
95
|
+
idleMs: 0,
|
|
96
|
+
touched: this.#now()
|
|
97
|
+
};
|
|
98
|
+
this.#meta = fresh;
|
|
99
|
+
return fresh;
|
|
100
|
+
}
|
|
101
|
+
async #storedMeta() {
|
|
102
|
+
if (this.#meta === void 0) this.#meta = await this.#storage.get(META);
|
|
103
|
+
return this.#meta;
|
|
104
|
+
}
|
|
105
|
+
async #loadMembers() {
|
|
106
|
+
if (this.#members === void 0) {
|
|
107
|
+
const rows = await this.#storage.list({ prefix: MEMBERS });
|
|
108
|
+
const members = /* @__PURE__ */ new Map();
|
|
109
|
+
for (const [key, record] of rows) members.set(key.slice(2), {
|
|
110
|
+
member: record.member,
|
|
111
|
+
expires: record.expires,
|
|
112
|
+
stored: record.expires
|
|
113
|
+
});
|
|
114
|
+
this.#members = members;
|
|
115
|
+
}
|
|
116
|
+
return this.#members;
|
|
117
|
+
}
|
|
118
|
+
/** Writes `puts` and `deletes` and the metadata in one transaction. */
|
|
119
|
+
async #commit(meta, puts, deletes) {
|
|
120
|
+
await this.#storage.transaction(async (transaction) => {
|
|
121
|
+
await transaction.put({
|
|
122
|
+
...puts,
|
|
123
|
+
[META]: meta
|
|
124
|
+
});
|
|
125
|
+
for (const batch of chunks(deletes)) await transaction.delete(batch);
|
|
126
|
+
});
|
|
127
|
+
this.#meta = meta;
|
|
128
|
+
}
|
|
129
|
+
#send(socket, text) {
|
|
130
|
+
try {
|
|
131
|
+
socket.send(text);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
this.#sockets.delete(socket);
|
|
135
|
+
try {
|
|
136
|
+
socket.close(1011, "write failed");
|
|
137
|
+
} catch {}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
#broadcast(frame) {
|
|
142
|
+
if (this.#sockets.size === 0) return;
|
|
143
|
+
const text = JSON.stringify(frame);
|
|
144
|
+
for (const socket of [...this.#sockets]) this.#send(socket, text);
|
|
145
|
+
}
|
|
146
|
+
#emit(op, member) {
|
|
147
|
+
this.#broadcast({
|
|
148
|
+
t: "pres",
|
|
149
|
+
op,
|
|
150
|
+
member
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Assigns the next seq, retains the message, remembers the key and sends the message to every attached socket.
|
|
155
|
+
*
|
|
156
|
+
* @param channel The channel name.
|
|
157
|
+
* @param entry The message.
|
|
158
|
+
* @param options Retention and the idempotency key.
|
|
159
|
+
* @param idleMs Forget the channel after this long without activity (0 keeps it).
|
|
160
|
+
* @returns The seq and whether the key was already accepted.
|
|
161
|
+
*/
|
|
162
|
+
publish(channel, entry, options, idleMs = 0) {
|
|
163
|
+
return this.#run(async () => {
|
|
164
|
+
const meta = await this.#metaFor(channel);
|
|
165
|
+
const now = this.#now();
|
|
166
|
+
if (options.key !== void 0) {
|
|
167
|
+
const known = await this.#storage.get(KEYS + options.key);
|
|
168
|
+
if (known !== void 0 && known.expires > now) return {
|
|
169
|
+
seq: known.seq,
|
|
170
|
+
duplicate: true
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const seq = meta.head + 1;
|
|
174
|
+
const message = {
|
|
175
|
+
seq,
|
|
176
|
+
event: entry.event,
|
|
177
|
+
data: entry.data,
|
|
178
|
+
at: now,
|
|
179
|
+
...entry.publisher === void 0 ? {} : { publisher: entry.publisher },
|
|
180
|
+
...entry.user === void 0 ? {} : { user: entry.user }
|
|
181
|
+
};
|
|
182
|
+
const plan = await this.#planTrim(meta, now, options.history, options.historyTtlMs, 1);
|
|
183
|
+
const next = {
|
|
184
|
+
...meta,
|
|
185
|
+
head: seq,
|
|
186
|
+
keep: options.history,
|
|
187
|
+
ttlMs: options.historyTtlMs,
|
|
188
|
+
idleMs,
|
|
189
|
+
touched: now,
|
|
190
|
+
oldest: plan.oldest,
|
|
191
|
+
oldestAt: plan.oldestAt
|
|
192
|
+
};
|
|
193
|
+
const puts = {};
|
|
194
|
+
if (options.history > 0) {
|
|
195
|
+
puts[historyKey(seq)] = message;
|
|
196
|
+
if (next.oldest === 0) {
|
|
197
|
+
next.oldest = seq;
|
|
198
|
+
next.oldestAt = now;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (options.key !== void 0) {
|
|
202
|
+
const expires = now + options.idempotencyTtlMs;
|
|
203
|
+
puts[KEYS + options.key] = {
|
|
204
|
+
seq,
|
|
205
|
+
expires
|
|
206
|
+
};
|
|
207
|
+
puts[`${KEY_INDEX}${pad(expires)}:${options.key}`] = 1;
|
|
208
|
+
const due = expires + SWEEP_SLACK_MS;
|
|
209
|
+
next.keysDue = next.keysDue === 0 ? due : Math.min(next.keysDue, due);
|
|
210
|
+
}
|
|
211
|
+
await this.#commit(next, puts, plan.deletes);
|
|
212
|
+
this.#broadcast({
|
|
213
|
+
t: "msg",
|
|
214
|
+
m: message
|
|
215
|
+
});
|
|
216
|
+
await this.#schedule(next);
|
|
217
|
+
return {
|
|
218
|
+
seq,
|
|
219
|
+
duplicate: false
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Works out which retained messages to delete so that, after `extra` more messages are stored, at most `keep` remain and
|
|
225
|
+
* none is older than `ttlMs`. It reads at most a few pages and deletes at most {@link TRIM_MAX} messages.
|
|
226
|
+
*/
|
|
227
|
+
async #planTrim(meta, now, keep, ttlMs, extra) {
|
|
228
|
+
const deletes = [];
|
|
229
|
+
let oldest = meta.oldest;
|
|
230
|
+
let oldestAt = meta.oldestAt;
|
|
231
|
+
if (oldest === 0) return {
|
|
232
|
+
deletes,
|
|
233
|
+
oldest: 0,
|
|
234
|
+
oldestAt: 0
|
|
235
|
+
};
|
|
236
|
+
const target = meta.head + 1 + extra - keep;
|
|
237
|
+
if (oldest < target) {
|
|
238
|
+
const stop = Math.min(target, meta.head + 1, oldest + TRIM_MAX);
|
|
239
|
+
for (let seq = oldest; seq < stop; seq += 1) deletes.push(historyKey(seq));
|
|
240
|
+
oldest = stop;
|
|
241
|
+
if (oldest > meta.head) return {
|
|
242
|
+
deletes,
|
|
243
|
+
oldest: 0,
|
|
244
|
+
oldestAt: 0
|
|
245
|
+
};
|
|
246
|
+
const first = await this.#firstFrom(oldest);
|
|
247
|
+
if (first === void 0) return {
|
|
248
|
+
deletes,
|
|
249
|
+
oldest: 0,
|
|
250
|
+
oldestAt: 0
|
|
251
|
+
};
|
|
252
|
+
oldest = first.seq;
|
|
253
|
+
oldestAt = first.at;
|
|
254
|
+
}
|
|
255
|
+
const cutoff = now - ttlMs;
|
|
256
|
+
while (oldestAt < cutoff && deletes.length < TRIM_MAX) {
|
|
257
|
+
const page = await this.#storage.list({
|
|
258
|
+
prefix: MESSAGES,
|
|
259
|
+
start: historyKey(oldest),
|
|
260
|
+
limit: BATCH
|
|
261
|
+
});
|
|
262
|
+
let survivor;
|
|
263
|
+
for (const message of page.values()) {
|
|
264
|
+
if (message.at >= cutoff || deletes.length >= TRIM_MAX) {
|
|
265
|
+
survivor = message;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
deletes.push(historyKey(message.seq));
|
|
269
|
+
}
|
|
270
|
+
if (survivor !== void 0) {
|
|
271
|
+
oldest = survivor.seq;
|
|
272
|
+
oldestAt = survivor.at;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
if (page.size < BATCH) return {
|
|
276
|
+
deletes,
|
|
277
|
+
oldest: 0,
|
|
278
|
+
oldestAt: 0
|
|
279
|
+
};
|
|
280
|
+
const last = deletes.length === 0 ? void 0 : deletes[deletes.length - 1];
|
|
281
|
+
if (last === void 0) break;
|
|
282
|
+
oldest = seqOfKey(last) + 1;
|
|
283
|
+
const next = await this.#firstFrom(oldest);
|
|
284
|
+
if (next === void 0) return {
|
|
285
|
+
deletes,
|
|
286
|
+
oldest: 0,
|
|
287
|
+
oldestAt: 0
|
|
288
|
+
};
|
|
289
|
+
oldest = next.seq;
|
|
290
|
+
oldestAt = next.at;
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
deletes,
|
|
294
|
+
oldest,
|
|
295
|
+
oldestAt
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async #firstFrom(seq) {
|
|
299
|
+
const page = await this.#storage.list({
|
|
300
|
+
prefix: MESSAGES,
|
|
301
|
+
start: historyKey(seq),
|
|
302
|
+
limit: 1
|
|
303
|
+
});
|
|
304
|
+
for (const message of page.values()) return message;
|
|
305
|
+
}
|
|
306
|
+
/** Whether retained messages are past their time or above the count of the last publish. */
|
|
307
|
+
#retentionDue(meta, now) {
|
|
308
|
+
if (meta.oldest === 0) return false;
|
|
309
|
+
return meta.head - meta.oldest + 1 > meta.keep || meta.oldestAt + meta.ttlMs < now;
|
|
310
|
+
}
|
|
311
|
+
async #retain(meta, now) {
|
|
312
|
+
if (!this.#retentionDue(meta, now)) return false;
|
|
313
|
+
const plan = await this.#planTrim(meta, now, meta.keep, meta.ttlMs, 0);
|
|
314
|
+
if (plan.deletes.length === 0 && plan.oldest === meta.oldest) return false;
|
|
315
|
+
await this.#commit({
|
|
316
|
+
...meta,
|
|
317
|
+
oldest: plan.oldest,
|
|
318
|
+
oldestAt: plan.oldestAt
|
|
319
|
+
}, {}, plan.deletes);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Reads retained messages after a seq.
|
|
324
|
+
*
|
|
325
|
+
* @param channel The channel name.
|
|
326
|
+
* @param after The last seq the caller holds.
|
|
327
|
+
* @param limit The most messages to return (0 asks for the head only).
|
|
328
|
+
* @returns A page; `more` says retained messages remain after it.
|
|
329
|
+
*/
|
|
330
|
+
history(channel, after, limit) {
|
|
331
|
+
return this.#run(async () => {
|
|
332
|
+
let meta = await this.#metaFor(channel);
|
|
333
|
+
if (await this.#retain(meta, this.#now())) meta = this.#meta ?? meta;
|
|
334
|
+
if (limit <= 0 || after >= meta.head || meta.oldest === 0) return {
|
|
335
|
+
messages: [],
|
|
336
|
+
head: meta.head,
|
|
337
|
+
oldest: meta.oldest,
|
|
338
|
+
more: false
|
|
339
|
+
};
|
|
340
|
+
const cap = Math.min(limit, PAGE_MESSAGES);
|
|
341
|
+
const rows = await this.#storage.list({
|
|
342
|
+
prefix: MESSAGES,
|
|
343
|
+
start: historyKey(Math.max(after + 1, meta.oldest)),
|
|
344
|
+
limit: cap
|
|
345
|
+
});
|
|
346
|
+
const messages = [];
|
|
347
|
+
let bytes = 0;
|
|
348
|
+
for (const message of rows.values()) {
|
|
349
|
+
if (messages.length > 0 && bytes + sizeOf(message) > PAGE_BYTES) break;
|
|
350
|
+
messages.push(message);
|
|
351
|
+
bytes += sizeOf(message);
|
|
352
|
+
}
|
|
353
|
+
const last = messages[messages.length - 1];
|
|
354
|
+
return {
|
|
355
|
+
messages,
|
|
356
|
+
head: meta.head,
|
|
357
|
+
oldest: meta.oldest,
|
|
358
|
+
more: last !== void 0 && last.seq < meta.head
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Starts sending a socket the channel's messages and presence events: the head, the live members, the messages after
|
|
364
|
+
* `after` that are retained, then every later event. Nothing can interleave, because it runs in the queue.
|
|
365
|
+
*
|
|
366
|
+
* @param socket The server end of the subscribe socket.
|
|
367
|
+
* @param channel The channel name.
|
|
368
|
+
* @param after The last seq the subscriber holds, when it is resuming.
|
|
369
|
+
*/
|
|
370
|
+
attach(socket, channel, after) {
|
|
371
|
+
return this.#run(async () => {
|
|
372
|
+
let meta = await this.#metaFor(channel);
|
|
373
|
+
const now = this.#now();
|
|
374
|
+
if (await this.#retain(meta, now)) meta = this.#meta ?? meta;
|
|
375
|
+
const members = await this.#loadMembers();
|
|
376
|
+
const live = [];
|
|
377
|
+
for (const state of members.values()) if (state.expires > now) live.push(state.member);
|
|
378
|
+
const frames = [JSON.stringify({
|
|
379
|
+
t: "ready",
|
|
380
|
+
head: meta.head,
|
|
381
|
+
oldest: meta.oldest
|
|
382
|
+
}), JSON.stringify({
|
|
383
|
+
t: "snap",
|
|
384
|
+
members: live
|
|
385
|
+
})];
|
|
386
|
+
let truncated = false;
|
|
387
|
+
if (after !== void 0 && after < meta.head && meta.oldest > 0) {
|
|
388
|
+
let cursor = Math.max(after, meta.oldest - 1);
|
|
389
|
+
let count = 0;
|
|
390
|
+
let bytes = 0;
|
|
391
|
+
replay: while (cursor < meta.head) {
|
|
392
|
+
const rows = await this.#storage.list({
|
|
393
|
+
prefix: MESSAGES,
|
|
394
|
+
start: historyKey(cursor + 1),
|
|
395
|
+
limit: BATCH
|
|
396
|
+
});
|
|
397
|
+
if (rows.size === 0) break;
|
|
398
|
+
for (const message of rows.values()) {
|
|
399
|
+
frames.push(JSON.stringify({
|
|
400
|
+
t: "msg",
|
|
401
|
+
m: message
|
|
402
|
+
}));
|
|
403
|
+
cursor = message.seq;
|
|
404
|
+
count += 1;
|
|
405
|
+
bytes += sizeOf(message);
|
|
406
|
+
if (count >= REPLAY_MESSAGES || bytes >= REPLAY_BYTES) {
|
|
407
|
+
truncated = cursor < meta.head;
|
|
408
|
+
break replay;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (rows.size < BATCH) break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (truncated) frames.push(JSON.stringify({ t: "more" }));
|
|
415
|
+
for (const frame of frames) try {
|
|
416
|
+
socket.send(frame);
|
|
417
|
+
} catch {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
this.#sockets.add(socket);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Adds or updates a member. An older version is ignored; the first sighting is a `join`, a newer version a `set`.
|
|
425
|
+
*
|
|
426
|
+
* @param channel The channel name.
|
|
427
|
+
* @param member The member.
|
|
428
|
+
* @param ttlMs Milliseconds until it expires without a touch.
|
|
429
|
+
* @param graceMs Milliseconds after expiry that the alarm waits before it reaps (so the hub's own reap comes first).
|
|
430
|
+
*/
|
|
431
|
+
presenceSet(channel, member, ttlMs, graceMs) {
|
|
432
|
+
return this.#run(async () => {
|
|
433
|
+
const meta = await this.#metaFor(channel);
|
|
434
|
+
const members = await this.#loadMembers();
|
|
435
|
+
const known = members.get(member.id);
|
|
436
|
+
if (known !== void 0 && known.member.v > member.v) return;
|
|
437
|
+
if (known === void 0 && members.size >= this.#maxMembers) throw new RealtimeError("limit", "the channel has too many presence members");
|
|
438
|
+
const now = this.#now();
|
|
439
|
+
const expires = now + ttlMs;
|
|
440
|
+
const changed = known === void 0 || known.member.v < member.v;
|
|
441
|
+
const due = expires + graceMs;
|
|
442
|
+
const next = {
|
|
443
|
+
...meta,
|
|
444
|
+
graceMs,
|
|
445
|
+
touched: now,
|
|
446
|
+
presenceDue: meta.presenceDue === 0 ? due : Math.min(meta.presenceDue, due)
|
|
447
|
+
};
|
|
448
|
+
const persist = changed || known === void 0 || expires - known.stored >= ttlMs / 2;
|
|
449
|
+
if (persist || next.presenceDue !== meta.presenceDue || next.graceMs !== meta.graceMs) await this.#commit(next, persist ? { [MEMBERS + member.id]: {
|
|
450
|
+
member,
|
|
451
|
+
expires
|
|
452
|
+
} } : {}, []);
|
|
453
|
+
members.set(member.id, {
|
|
454
|
+
member,
|
|
455
|
+
expires,
|
|
456
|
+
stored: persist ? expires : known?.stored ?? expires
|
|
457
|
+
});
|
|
458
|
+
if (known === void 0) this.#emit("join", member);
|
|
459
|
+
else if (known.member.v < member.v) this.#emit("set", member);
|
|
460
|
+
await this.#schedule(next);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Removes a member.
|
|
465
|
+
*
|
|
466
|
+
* @param channel The channel name.
|
|
467
|
+
* @param id The member id.
|
|
468
|
+
* @returns True when the member existed (and a `leave` was sent); false otherwise.
|
|
469
|
+
*/
|
|
470
|
+
presenceRemove(channel, id) {
|
|
471
|
+
return this.#run(async () => {
|
|
472
|
+
const meta = await this.#metaFor(channel);
|
|
473
|
+
const members = await this.#loadMembers();
|
|
474
|
+
const known = members.get(id);
|
|
475
|
+
if (known === void 0) return false;
|
|
476
|
+
const next = members.size === 1 ? {
|
|
477
|
+
...meta,
|
|
478
|
+
presenceDue: 0
|
|
479
|
+
} : meta;
|
|
480
|
+
await this.#commit(next, {}, [MEMBERS + id]);
|
|
481
|
+
members.delete(id);
|
|
482
|
+
this.#emit("leave", known.member);
|
|
483
|
+
await this.#schedule(next);
|
|
484
|
+
return true;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The members that have not expired.
|
|
489
|
+
*
|
|
490
|
+
* @param channel The channel name.
|
|
491
|
+
* @returns The members.
|
|
492
|
+
*/
|
|
493
|
+
presenceList(channel) {
|
|
494
|
+
return this.#run(async () => {
|
|
495
|
+
await this.#metaFor(channel);
|
|
496
|
+
const members = await this.#loadMembers();
|
|
497
|
+
const now = this.#now();
|
|
498
|
+
const out = [];
|
|
499
|
+
for (const state of members.values()) if (state.expires > now) out.push(state.member);
|
|
500
|
+
return out;
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Extends the expiry of members. The storage is written only when the new expiry is at least half a time to live beyond
|
|
505
|
+
* the stored one, so a heartbeat every few seconds costs a write every few beats; a restart in between loses at most
|
|
506
|
+
* that half, which a heartbeat repairs long before the member would expire.
|
|
507
|
+
*
|
|
508
|
+
* @param channel The channel name.
|
|
509
|
+
* @param ids The member ids.
|
|
510
|
+
* @param ttlMs Milliseconds until they expire without another touch.
|
|
511
|
+
* @returns The ids that no longer exist.
|
|
512
|
+
*/
|
|
513
|
+
presenceTouch(channel, ids, ttlMs) {
|
|
514
|
+
return this.#run(async () => {
|
|
515
|
+
const meta = await this.#metaFor(channel);
|
|
516
|
+
const members = await this.#loadMembers();
|
|
517
|
+
const expires = this.#now() + ttlMs;
|
|
518
|
+
const missing = [];
|
|
519
|
+
const puts = {};
|
|
520
|
+
const written = [];
|
|
521
|
+
let touched = 0;
|
|
522
|
+
for (const id of ids) {
|
|
523
|
+
const state = members.get(id);
|
|
524
|
+
if (state === void 0) {
|
|
525
|
+
missing.push(id);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
state.expires = expires;
|
|
529
|
+
touched += 1;
|
|
530
|
+
if (state.expires - state.stored >= ttlMs / 2) {
|
|
531
|
+
puts[MEMBERS + id] = {
|
|
532
|
+
member: state.member,
|
|
533
|
+
expires: state.expires
|
|
534
|
+
};
|
|
535
|
+
written.push(state);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const keys = Object.keys(puts);
|
|
539
|
+
for (const batch of chunks(keys)) {
|
|
540
|
+
const part = {};
|
|
541
|
+
for (const key of batch) part[key] = puts[key];
|
|
542
|
+
await this.#storage.put(part);
|
|
543
|
+
}
|
|
544
|
+
for (const state of written) state.stored = state.expires;
|
|
545
|
+
const due = expires + meta.graceMs;
|
|
546
|
+
if (touched > 0 && (meta.presenceDue === 0 || due < meta.presenceDue)) {
|
|
547
|
+
const next = {
|
|
548
|
+
...meta,
|
|
549
|
+
presenceDue: due
|
|
550
|
+
};
|
|
551
|
+
await this.#commit(next, {}, []);
|
|
552
|
+
await this.#schedule(next);
|
|
553
|
+
}
|
|
554
|
+
return missing;
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Removes every expired member and sends one `leave` for each.
|
|
559
|
+
*
|
|
560
|
+
* @param channel The channel name.
|
|
561
|
+
* @returns The members removed.
|
|
562
|
+
*/
|
|
563
|
+
presenceReap(channel) {
|
|
564
|
+
return this.#run(async () => {
|
|
565
|
+
const meta = await this.#metaFor(channel);
|
|
566
|
+
return this.#reap(meta, this.#now());
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
async #reap(meta, now) {
|
|
570
|
+
const members = await this.#loadMembers();
|
|
571
|
+
const gone = [];
|
|
572
|
+
for (const state of members.values()) if (state.expires <= now) gone.push(state);
|
|
573
|
+
if (gone.length === 0) return [];
|
|
574
|
+
let due = 0;
|
|
575
|
+
for (const state of members.values()) if (state.expires > now) {
|
|
576
|
+
const at = state.expires + meta.graceMs;
|
|
577
|
+
due = due === 0 ? at : Math.min(due, at);
|
|
578
|
+
}
|
|
579
|
+
const next = {
|
|
580
|
+
...meta,
|
|
581
|
+
presenceDue: due
|
|
582
|
+
};
|
|
583
|
+
await this.#commit(next, {}, gone.map((state) => MEMBERS + state.member.id));
|
|
584
|
+
for (const state of gone) {
|
|
585
|
+
members.delete(state.member.id);
|
|
586
|
+
this.#emit("leave", state.member);
|
|
587
|
+
}
|
|
588
|
+
return gone.map((state) => state.member);
|
|
589
|
+
}
|
|
590
|
+
/** The time the next alarm is due, `undefined` when nothing is pending. */
|
|
591
|
+
#nextDue(meta, now) {
|
|
592
|
+
let due = 0;
|
|
593
|
+
const consider = (at) => {
|
|
594
|
+
if (at > 0 && (due === 0 || at < due)) due = at;
|
|
595
|
+
};
|
|
596
|
+
consider(meta.presenceDue);
|
|
597
|
+
consider(meta.keysDue);
|
|
598
|
+
if (meta.oldest > 0) consider(meta.head - meta.oldest + 1 > meta.keep ? now : meta.oldestAt + meta.ttlMs + HISTORY_SLACK_MS);
|
|
599
|
+
if (meta.idleMs > 0) {
|
|
600
|
+
const idleAt = meta.touched + meta.idleMs;
|
|
601
|
+
consider(this.#sockets.size > 0 && idleAt <= now ? now + meta.idleMs : idleAt);
|
|
602
|
+
}
|
|
603
|
+
return due === 0 ? void 0 : Math.max(due, now);
|
|
604
|
+
}
|
|
605
|
+
/** Makes the storage's alarm fire no later than the next thing that is due. An earlier alarm is left alone: it wakes, finds nothing, and sets the right time. */
|
|
606
|
+
async #schedule(meta) {
|
|
607
|
+
const now = this.#now();
|
|
608
|
+
const due = this.#nextDue(meta, now);
|
|
609
|
+
if (this.#alarmAt === void 0) this.#alarmAt = await this.#storage.getAlarm();
|
|
610
|
+
if (due === void 0) {
|
|
611
|
+
if (this.#alarmAt !== null) {
|
|
612
|
+
await this.#storage.deleteAlarm();
|
|
613
|
+
this.#alarmAt = null;
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (this.#alarmAt === null || this.#alarmAt > due) {
|
|
618
|
+
await this.#storage.setAlarm(due);
|
|
619
|
+
this.#alarmAt = due;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/** Deletes idempotency keys that expired, in batches. */
|
|
623
|
+
async #sweepKeys(meta, now) {
|
|
624
|
+
for (let pass = 0; pass < 32; pass += 1) {
|
|
625
|
+
const rows = await this.#storage.list({
|
|
626
|
+
prefix: KEY_INDEX,
|
|
627
|
+
end: KEY_INDEX + pad(now + 1),
|
|
628
|
+
limit: BATCH
|
|
629
|
+
});
|
|
630
|
+
if (rows.size === 0) break;
|
|
631
|
+
const indexKeys = [...rows.keys()];
|
|
632
|
+
const names = indexKeys.map((key) => key.slice(KEY_INDEX_PREFIX));
|
|
633
|
+
const records = await this.#storage.get(names.map((name) => KEYS + name));
|
|
634
|
+
const deletes = [...indexKeys];
|
|
635
|
+
for (const name of names) {
|
|
636
|
+
const record = records.get(KEYS + name);
|
|
637
|
+
if (record === void 0 || record.expires <= now) deletes.push(KEYS + name);
|
|
638
|
+
}
|
|
639
|
+
await this.#storage.transaction(async (transaction) => {
|
|
640
|
+
for (const batch of chunks(deletes)) await transaction.delete(batch);
|
|
641
|
+
});
|
|
642
|
+
if (rows.size < BATCH) break;
|
|
643
|
+
}
|
|
644
|
+
const first = await this.#storage.list({
|
|
645
|
+
prefix: KEY_INDEX,
|
|
646
|
+
limit: 1
|
|
647
|
+
});
|
|
648
|
+
let keysDue = 0;
|
|
649
|
+
for (const key of first.keys()) keysDue = Number(key.slice(2, 18)) + SWEEP_SLACK_MS;
|
|
650
|
+
return {
|
|
651
|
+
...meta,
|
|
652
|
+
keysDue
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* The alarm handler: reaps expired presence members (one `leave` each), sweeps expired idempotency keys, trims retained
|
|
657
|
+
* messages that expired, forgets a channel that has been idle for its `idleMs`, and sets the next alarm. It is safe to run
|
|
658
|
+
* more often than needed, and to run again after a failure.
|
|
659
|
+
*/
|
|
660
|
+
alarm() {
|
|
661
|
+
return this.#run(async () => {
|
|
662
|
+
this.#alarmAt = null;
|
|
663
|
+
let meta = await this.#storedMeta();
|
|
664
|
+
if (meta === void 0) return;
|
|
665
|
+
const now = this.#now();
|
|
666
|
+
if (meta.presenceDue > 0 && meta.presenceDue <= now) {
|
|
667
|
+
await this.#reap(meta, now);
|
|
668
|
+
meta = this.#meta ?? meta;
|
|
669
|
+
let due = 0;
|
|
670
|
+
for (const state of (await this.#loadMembers()).values()) {
|
|
671
|
+
const at = state.expires + meta.graceMs;
|
|
672
|
+
due = due === 0 ? at : Math.min(due, at);
|
|
673
|
+
}
|
|
674
|
+
if (due !== meta.presenceDue) {
|
|
675
|
+
meta = {
|
|
676
|
+
...meta,
|
|
677
|
+
presenceDue: due
|
|
678
|
+
};
|
|
679
|
+
await this.#commit(meta, {}, []);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (meta.keysDue > 0 && meta.keysDue <= now) {
|
|
683
|
+
meta = await this.#sweepKeys(meta, now);
|
|
684
|
+
await this.#commit(meta, {}, []);
|
|
685
|
+
}
|
|
686
|
+
if (await this.#retain(meta, now)) meta = this.#meta ?? meta;
|
|
687
|
+
if (meta.idleMs > 0 && meta.touched + meta.idleMs <= now && meta.oldest === 0 && meta.presenceDue === 0 && meta.keysDue === 0 && this.#sockets.size === 0) {
|
|
688
|
+
await this.#storage.deleteAll();
|
|
689
|
+
this.#meta = void 0;
|
|
690
|
+
this.#members = void 0;
|
|
691
|
+
this.#alarmAt = null;
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
await this.#schedule(meta);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
//#endregion
|
|
699
|
+
export { ChannelCore };
|
|
700
|
+
|
|
701
|
+
//# sourceMappingURL=core.js.map
|