@lenso/realtime 0.0.0-stage → 0.2.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/README.md +420 -2
- package/dist/config.d.ts +3 -0
- package/dist/contracts.d.ts +119 -0
- package/dist/index-6fg21p7s.js +563 -0
- package/dist/index-htkqerky.js +81 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +12 -0
- package/dist/memory.d.ts +5 -0
- package/dist/memory.js +62 -0
- package/dist/plugin.d.ts +30 -0
- package/dist/plugin.js +68 -0
- package/dist/redis.d.ts +11 -0
- package/dist/redis.js +275 -0
- package/dist/wire.d.ts +10 -0
- package/examples/notes.ts +65 -0
- package/examples/run-notes.ts +43 -0
- package/examples/tasks.ts +10 -0
- package/package.json +53 -4
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RealtimeError2,
|
|
3
|
+
part,
|
|
4
|
+
resourceTopic,
|
|
5
|
+
cursorToken,
|
|
6
|
+
parseCursor,
|
|
7
|
+
sameCursor,
|
|
8
|
+
serialize
|
|
9
|
+
} from "./index-htkqerky.js";
|
|
10
|
+
// src/config.ts
|
|
11
|
+
var defaults = {
|
|
12
|
+
maxConnections: 1000,
|
|
13
|
+
maxConnectionsPerSubject: 8,
|
|
14
|
+
maxSubscriptionsPerConnection: 16,
|
|
15
|
+
maxSubscriptionsPerSubject: 64,
|
|
16
|
+
maxSubscribersPerTopic: 1000,
|
|
17
|
+
maxTopics: 1000,
|
|
18
|
+
maxPendingOperations: 64,
|
|
19
|
+
maxPayloadBytes: 16384,
|
|
20
|
+
maxBufferedEvents: 64,
|
|
21
|
+
maxBufferedBytes: 131072,
|
|
22
|
+
authorizationLeaseMs: 30000,
|
|
23
|
+
sweepMs: 250,
|
|
24
|
+
heartbeatMs: 15000,
|
|
25
|
+
retryMs: 2000,
|
|
26
|
+
cursorMaxAgeMs: 600000,
|
|
27
|
+
snapshotTimeoutMs: 5000
|
|
28
|
+
};
|
|
29
|
+
function resolveRealtimeConfig2(input = {}) {
|
|
30
|
+
const config = { ...defaults, ...input };
|
|
31
|
+
for (const key of Object.keys(config)) {
|
|
32
|
+
const value = config[key];
|
|
33
|
+
if (!(key in defaults) || !Number.isSafeInteger(value) || value < 1 || value > 1e7)
|
|
34
|
+
throw new RealtimeError2("invalid-input");
|
|
35
|
+
}
|
|
36
|
+
if (config.authorizationLeaseMs > 30000 || config.sweepMs > 1000 || config.sweepMs > config.authorizationLeaseMs || config.maxPayloadBytes > 32768 || config.maxBufferedBytes < 512 || config.maxBufferedBytes < config.maxPayloadBytes + 512)
|
|
37
|
+
throw new RealtimeError2("invalid-input");
|
|
38
|
+
return config;
|
|
39
|
+
}
|
|
40
|
+
// src/index.ts
|
|
41
|
+
async function createRealtime2(options) {
|
|
42
|
+
const config = resolveRealtimeConfig2(options.config);
|
|
43
|
+
const provider = options.provider;
|
|
44
|
+
const connections = new Set;
|
|
45
|
+
const topics = new Map;
|
|
46
|
+
let stopped = false;
|
|
47
|
+
let failed = false;
|
|
48
|
+
let pending = 0;
|
|
49
|
+
let closing;
|
|
50
|
+
let sweep;
|
|
51
|
+
const lifetime = new AbortController;
|
|
52
|
+
const diagnostic = (event) => {
|
|
53
|
+
try {
|
|
54
|
+
options.onDiagnostic?.(event);
|
|
55
|
+
} catch {}
|
|
56
|
+
};
|
|
57
|
+
const assertOpen = () => {
|
|
58
|
+
if (failed)
|
|
59
|
+
throw new RealtimeError2("provider");
|
|
60
|
+
if (stopped)
|
|
61
|
+
throw new RealtimeError2("closed");
|
|
62
|
+
};
|
|
63
|
+
const live = (connection) => {
|
|
64
|
+
assertOpen();
|
|
65
|
+
if (connection.closed)
|
|
66
|
+
throw new RealtimeError2("closed");
|
|
67
|
+
if (connection.identity.expiresAt <= Date.now()) {
|
|
68
|
+
closeConnection(connection, { version: 1, kind: "closed", reason: "expired" });
|
|
69
|
+
throw new RealtimeError2("expired");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const subjectConnections = (identity) => [...connections].filter((c) => c.identity.scope === identity.scope && c.identity.subject === identity.subject);
|
|
73
|
+
const purge = (connection, subscription) => {
|
|
74
|
+
connection.queue = connection.queue.filter((item) => JSON.parse(item.text).subscription !== subscription);
|
|
75
|
+
connection.bytes = connection.queue.reduce((sum, item) => sum + item.bytes, 0);
|
|
76
|
+
};
|
|
77
|
+
const removeSub = (sub, reason, notify = true) => {
|
|
78
|
+
if (!sub.connection.subscriptions.delete(sub.handle.id))
|
|
79
|
+
return;
|
|
80
|
+
sub.abort.abort(new RealtimeError2("closed"));
|
|
81
|
+
const listeners = topics.get(sub.topic);
|
|
82
|
+
listeners?.delete(sub);
|
|
83
|
+
if (!listeners?.size)
|
|
84
|
+
topics.delete(sub.topic);
|
|
85
|
+
purge(sub.connection, sub.handle.id);
|
|
86
|
+
if (notify)
|
|
87
|
+
push(sub.connection, { version: 1, kind: "closed", subscription: sub.handle.id, reason });
|
|
88
|
+
};
|
|
89
|
+
function closeConnection(connection, terminal) {
|
|
90
|
+
if (connection.closed)
|
|
91
|
+
return;
|
|
92
|
+
connection.closed = true;
|
|
93
|
+
connections.delete(connection);
|
|
94
|
+
connection.removeAbort();
|
|
95
|
+
connection.abort.abort(new RealtimeError2("closed"));
|
|
96
|
+
for (const sub of [...connection.subscriptions.values()])
|
|
97
|
+
removeSub(sub, "shutdown", false);
|
|
98
|
+
connection.queue = [];
|
|
99
|
+
connection.bytes = 0;
|
|
100
|
+
connection.terminal = terminal;
|
|
101
|
+
if (connection.waiter) {
|
|
102
|
+
const waiter = connection.waiter;
|
|
103
|
+
connection.waiter = undefined;
|
|
104
|
+
connection.terminal = undefined;
|
|
105
|
+
waiter(terminal ? { done: false, value: terminal } : { done: true, value: undefined });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function push(connection, event) {
|
|
109
|
+
if (connection.closed)
|
|
110
|
+
return;
|
|
111
|
+
if (connection.identity.expiresAt <= Date.now()) {
|
|
112
|
+
closeConnection(connection, { version: 1, kind: "closed", reason: "expired" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const text = JSON.stringify(event);
|
|
116
|
+
if (new TextEncoder().encode(text).byteLength > config.maxPayloadBytes + 512) {
|
|
117
|
+
diagnostic("overflow");
|
|
118
|
+
closeConnection(connection, { version: 1, kind: "gap", reason: "overflow" });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (connection.waiter) {
|
|
122
|
+
const waiter = connection.waiter;
|
|
123
|
+
connection.waiter = undefined;
|
|
124
|
+
waiter({ done: false, value: JSON.parse(text) });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const bytes = new TextEncoder().encode(text).byteLength;
|
|
128
|
+
if (connection.queue.length >= config.maxBufferedEvents || connection.bytes + bytes > config.maxBufferedBytes) {
|
|
129
|
+
diagnostic("overflow");
|
|
130
|
+
closeConnection(connection, { version: 1, kind: "gap", reason: "overflow" });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
connection.queue.push({ text, bytes });
|
|
134
|
+
connection.bytes += bytes;
|
|
135
|
+
}
|
|
136
|
+
const providerFailed = () => {
|
|
137
|
+
if (failed || stopped)
|
|
138
|
+
return;
|
|
139
|
+
failed = true;
|
|
140
|
+
clearInterval(sweep);
|
|
141
|
+
lifetime.abort(new RealtimeError2("provider"));
|
|
142
|
+
diagnostic("provider");
|
|
143
|
+
for (const connection of [...connections])
|
|
144
|
+
closeConnection(connection, { version: 1, kind: "gap", reason: "provider" });
|
|
145
|
+
provider.close().catch(() => {});
|
|
146
|
+
};
|
|
147
|
+
const receive = (delivery) => {
|
|
148
|
+
if (failed || stopped)
|
|
149
|
+
return;
|
|
150
|
+
const listeners = topics.get(delivery.topic);
|
|
151
|
+
if (!listeners)
|
|
152
|
+
return;
|
|
153
|
+
serialize(delivery.event, config.maxPayloadBytes);
|
|
154
|
+
const token = cursorToken(delivery.cursor);
|
|
155
|
+
for (const sub of [...listeners]) {
|
|
156
|
+
if (delivery.event.kind === "revoke" || delivery.event.kind === "deleted") {
|
|
157
|
+
removeSub(sub, delivery.event.kind === "revoke" ? "revoked" : "deleted");
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (sub.initializing)
|
|
161
|
+
continue;
|
|
162
|
+
if (sub.validUntil <= Date.now()) {
|
|
163
|
+
removeSub(sub, "expired");
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const previous = sub.cursor;
|
|
167
|
+
const next = delivery.cursor;
|
|
168
|
+
if (sameCursor(previous, next))
|
|
169
|
+
continue;
|
|
170
|
+
let reason;
|
|
171
|
+
if (next.generation !== previous.generation)
|
|
172
|
+
reason = "generation";
|
|
173
|
+
else if (next.sequence < previous.sequence)
|
|
174
|
+
reason = "out-of-order";
|
|
175
|
+
else if (next.sequence !== previous.sequence + 1)
|
|
176
|
+
reason = "sequence";
|
|
177
|
+
if (reason)
|
|
178
|
+
push(sub.connection, {
|
|
179
|
+
version: 1,
|
|
180
|
+
kind: "gap",
|
|
181
|
+
subscription: sub.handle.id,
|
|
182
|
+
reason,
|
|
183
|
+
cursor: token
|
|
184
|
+
});
|
|
185
|
+
if (reason === "out-of-order")
|
|
186
|
+
continue;
|
|
187
|
+
sub.cursor = { ...next };
|
|
188
|
+
push(sub.connection, {
|
|
189
|
+
version: 1,
|
|
190
|
+
kind: "update",
|
|
191
|
+
subscription: sub.handle.id,
|
|
192
|
+
cursor: token,
|
|
193
|
+
type: delivery.event.type,
|
|
194
|
+
data: delivery.event.data
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const deliver = (delivery) => {
|
|
199
|
+
try {
|
|
200
|
+
receive(delivery);
|
|
201
|
+
} catch {
|
|
202
|
+
providerFailed();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
await provider.start(deliver, providerFailed);
|
|
207
|
+
assertOpen();
|
|
208
|
+
} catch {
|
|
209
|
+
await provider.close().catch(() => {});
|
|
210
|
+
throw new RealtimeError2("provider");
|
|
211
|
+
}
|
|
212
|
+
const run = async (signal, operation) => {
|
|
213
|
+
assertOpen();
|
|
214
|
+
if (signal.aborted)
|
|
215
|
+
throw new RealtimeError2("closed");
|
|
216
|
+
if (pending >= config.maxPendingOperations)
|
|
217
|
+
throw new RealtimeError2("limit");
|
|
218
|
+
pending++;
|
|
219
|
+
const abort = new AbortController;
|
|
220
|
+
const cancelled = () => abort.abort(new RealtimeError2("closed"));
|
|
221
|
+
const shutdown = () => abort.abort(new RealtimeError2(failed ? "provider" : "closed"));
|
|
222
|
+
signal.addEventListener("abort", cancelled, { once: true });
|
|
223
|
+
lifetime.signal.addEventListener("abort", shutdown, { once: true });
|
|
224
|
+
const timer = setTimeout(() => abort.abort(new RealtimeError2("expired")), config.snapshotTimeoutMs);
|
|
225
|
+
let rejectAbort;
|
|
226
|
+
const interrupted = new Promise((_, reject) => {
|
|
227
|
+
rejectAbort = reject;
|
|
228
|
+
});
|
|
229
|
+
const onAbort = () => rejectAbort(abort.signal.reason);
|
|
230
|
+
abort.signal.addEventListener("abort", onAbort, { once: true });
|
|
231
|
+
const work = Promise.resolve().then(() => operation(abort.signal)).finally(() => {
|
|
232
|
+
pending--;
|
|
233
|
+
});
|
|
234
|
+
try {
|
|
235
|
+
return await Promise.race([work, interrupted]);
|
|
236
|
+
} finally {
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
signal.removeEventListener("abort", cancelled);
|
|
239
|
+
lifetime.signal.removeEventListener("abort", shutdown);
|
|
240
|
+
abort.signal.removeEventListener("abort", onAbort);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const current = async (topic) => {
|
|
244
|
+
try {
|
|
245
|
+
return await provider.current(topic);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof RealtimeError2 && error.code === "limit")
|
|
248
|
+
throw error;
|
|
249
|
+
providerFailed();
|
|
250
|
+
throw new RealtimeError2("provider");
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const authorize = async (sub) => {
|
|
254
|
+
const startedAt = Date.now();
|
|
255
|
+
let result;
|
|
256
|
+
try {
|
|
257
|
+
result = await run(sub.abort.signal, (signal) => options.authorize(sub.connection.identity, sub.handle.resource, signal));
|
|
258
|
+
} catch (error) {
|
|
259
|
+
diagnostic("authorization");
|
|
260
|
+
if (error instanceof RealtimeError2)
|
|
261
|
+
throw error;
|
|
262
|
+
throw new RealtimeError2("denied");
|
|
263
|
+
}
|
|
264
|
+
live(sub.connection);
|
|
265
|
+
if (!sub.connection.subscriptions.has(sub.handle.id))
|
|
266
|
+
throw new RealtimeError2("closed");
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
if (!result || !Number.isFinite(result.validUntil) || result.validUntil <= now)
|
|
269
|
+
throw new RealtimeError2("denied");
|
|
270
|
+
sub.validUntil = Math.min(result.validUntil, startedAt + config.authorizationLeaseMs, sub.connection.identity.expiresAt);
|
|
271
|
+
if (sub.validUntil <= now)
|
|
272
|
+
throw new RealtimeError2("expired");
|
|
273
|
+
};
|
|
274
|
+
const next = async (connection) => {
|
|
275
|
+
for (const sub of [...connection.subscriptions.values()])
|
|
276
|
+
if (!sub.initializing && sub.validUntil <= Date.now())
|
|
277
|
+
removeSub(sub, "expired");
|
|
278
|
+
if (!connection.closed && connection.identity.expiresAt <= Date.now())
|
|
279
|
+
closeConnection(connection, { version: 1, kind: "closed", reason: "expired" });
|
|
280
|
+
const item = connection.queue.shift();
|
|
281
|
+
if (item) {
|
|
282
|
+
connection.bytes -= item.bytes;
|
|
283
|
+
return { done: false, value: JSON.parse(item.text) };
|
|
284
|
+
}
|
|
285
|
+
if (connection.terminal) {
|
|
286
|
+
const value = connection.terminal;
|
|
287
|
+
connection.terminal = undefined;
|
|
288
|
+
return { done: false, value };
|
|
289
|
+
}
|
|
290
|
+
if (connection.closed)
|
|
291
|
+
return { done: true, value: undefined };
|
|
292
|
+
if (connection.waiter)
|
|
293
|
+
throw new RealtimeError2("invalid-input");
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
connection.waiter = resolve;
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
sweep = setInterval(() => {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
for (const connection of [...connections]) {
|
|
301
|
+
if (connection.identity.expiresAt <= now) {
|
|
302
|
+
closeConnection(connection, { version: 1, kind: "closed", reason: "expired" });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
for (const sub of [...connection.subscriptions.values()])
|
|
306
|
+
if (!sub.initializing && sub.validUntil <= now)
|
|
307
|
+
removeSub(sub, "expired");
|
|
308
|
+
if (now - connection.heartbeatAt >= config.heartbeatMs) {
|
|
309
|
+
connection.heartbeatAt = now;
|
|
310
|
+
push(connection, { version: 1, kind: "heartbeat" });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}, config.sweepMs);
|
|
314
|
+
const publish = async (resource, event) => {
|
|
315
|
+
assertOpen();
|
|
316
|
+
const topic = resourceTopic(resource);
|
|
317
|
+
const copy = JSON.parse(serialize(event, config.maxPayloadBytes));
|
|
318
|
+
try {
|
|
319
|
+
return cursorToken(await run(new AbortController().signal, () => provider.publish(topic, copy)));
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (error instanceof RealtimeError2 && (error.code === "limit" || error.code === "payload"))
|
|
322
|
+
throw error;
|
|
323
|
+
providerFailed();
|
|
324
|
+
throw new RealtimeError2("provider");
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
connect(identity, { signal } = {}) {
|
|
329
|
+
assertOpen();
|
|
330
|
+
part(identity.scope);
|
|
331
|
+
part(identity.subject);
|
|
332
|
+
if (!Number.isFinite(identity.expiresAt) || identity.expiresAt <= Date.now())
|
|
333
|
+
throw new RealtimeError2("expired");
|
|
334
|
+
if (signal?.aborted)
|
|
335
|
+
throw new RealtimeError2("closed");
|
|
336
|
+
if (connections.size >= config.maxConnections || subjectConnections(identity).length >= config.maxConnectionsPerSubject)
|
|
337
|
+
throw new RealtimeError2("limit");
|
|
338
|
+
const connection = {
|
|
339
|
+
identity: Object.freeze({ ...identity }),
|
|
340
|
+
abort: new AbortController,
|
|
341
|
+
subscriptions: new Map,
|
|
342
|
+
queue: [],
|
|
343
|
+
bytes: 0,
|
|
344
|
+
closed: false,
|
|
345
|
+
consumed: false,
|
|
346
|
+
heartbeatAt: Date.now(),
|
|
347
|
+
removeAbort: () => {}
|
|
348
|
+
};
|
|
349
|
+
const disconnect = () => closeConnection(connection);
|
|
350
|
+
signal?.addEventListener("abort", disconnect, { once: true });
|
|
351
|
+
connection.removeAbort = () => signal?.removeEventListener("abort", disconnect);
|
|
352
|
+
connections.add(connection);
|
|
353
|
+
const api = {
|
|
354
|
+
async subscribe(input, subscriptionOptions = {}) {
|
|
355
|
+
live(connection);
|
|
356
|
+
const resource = Object.freeze({ scope: input.scope, type: input.type, id: input.id });
|
|
357
|
+
const topic = resourceTopic(resource);
|
|
358
|
+
if (resource.scope !== connection.identity.scope)
|
|
359
|
+
throw new RealtimeError2("denied");
|
|
360
|
+
const subjectCount = subjectConnections(connection.identity).reduce((sum, c) => sum + c.subscriptions.size, 0);
|
|
361
|
+
if (connection.subscriptions.size >= config.maxSubscriptionsPerConnection || subjectCount >= config.maxSubscriptionsPerSubject || (topics.get(topic)?.size ?? 0) >= config.maxSubscribersPerTopic || !topics.has(topic) && topics.size >= config.maxTopics)
|
|
362
|
+
throw new RealtimeError2("limit");
|
|
363
|
+
const id = crypto.randomUUID();
|
|
364
|
+
const sub = {
|
|
365
|
+
topic,
|
|
366
|
+
connection,
|
|
367
|
+
abort: new AbortController,
|
|
368
|
+
validUntil: 0,
|
|
369
|
+
initializing: true,
|
|
370
|
+
renewing: false,
|
|
371
|
+
handle: Object.freeze({
|
|
372
|
+
id,
|
|
373
|
+
resource,
|
|
374
|
+
unsubscribe: () => removeSub(sub, "unsubscribed"),
|
|
375
|
+
async renew() {
|
|
376
|
+
live(connection);
|
|
377
|
+
if (!connection.subscriptions.has(id))
|
|
378
|
+
throw new RealtimeError2("closed");
|
|
379
|
+
if (sub.validUntil <= Date.now()) {
|
|
380
|
+
removeSub(sub, "expired");
|
|
381
|
+
throw new RealtimeError2("expired");
|
|
382
|
+
}
|
|
383
|
+
if (sub.renewing)
|
|
384
|
+
throw new RealtimeError2("limit");
|
|
385
|
+
sub.renewing = true;
|
|
386
|
+
try {
|
|
387
|
+
await authorize(sub);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
removeSub(sub, "revoked");
|
|
390
|
+
throw error;
|
|
391
|
+
} finally {
|
|
392
|
+
sub.renewing = false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
};
|
|
397
|
+
connection.subscriptions.set(id, sub);
|
|
398
|
+
if (!topics.has(topic))
|
|
399
|
+
topics.set(topic, new Set);
|
|
400
|
+
topics.get(topic).add(sub);
|
|
401
|
+
try {
|
|
402
|
+
await authorize(sub);
|
|
403
|
+
sub.cursor = await run(sub.abort.signal, () => current(topic));
|
|
404
|
+
live(connection);
|
|
405
|
+
if (!connection.subscriptions.has(id) || sub.validUntil <= Date.now())
|
|
406
|
+
throw new RealtimeError2("expired");
|
|
407
|
+
sub.initializing = false;
|
|
408
|
+
push(connection, {
|
|
409
|
+
version: 1,
|
|
410
|
+
kind: "ready",
|
|
411
|
+
subscription: id,
|
|
412
|
+
cursor: cursorToken(sub.cursor)
|
|
413
|
+
});
|
|
414
|
+
if (subscriptionOptions.cursor !== undefined) {
|
|
415
|
+
const parsed = parseCursor(subscriptionOptions.cursor);
|
|
416
|
+
const age = parsed ? Date.now() - parsed.issuedAt : Infinity;
|
|
417
|
+
push(connection, {
|
|
418
|
+
version: 1,
|
|
419
|
+
kind: "gap",
|
|
420
|
+
subscription: id,
|
|
421
|
+
cursor: cursorToken(sub.cursor),
|
|
422
|
+
reason: !parsed || age < 0 || age > config.cursorMaxAgeMs ? "cursor-expired" : "reconnect"
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
live(connection);
|
|
426
|
+
return sub.handle;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
removeSub(sub, "revoked", false);
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
async snapshot(subscription, read) {
|
|
433
|
+
live(connection);
|
|
434
|
+
const sub = connection.subscriptions.get(subscription.id);
|
|
435
|
+
if (!sub || sub.handle !== subscription || sub.initializing)
|
|
436
|
+
throw new RealtimeError2("denied");
|
|
437
|
+
if (sub.validUntil <= Date.now()) {
|
|
438
|
+
removeSub(sub, "expired");
|
|
439
|
+
throw new RealtimeError2("expired");
|
|
440
|
+
}
|
|
441
|
+
const result = await run(sub.abort.signal, async (snapshotSignal) => {
|
|
442
|
+
const before = await current(sub.topic);
|
|
443
|
+
const value = await read(snapshotSignal);
|
|
444
|
+
snapshotSignal.throwIfAborted();
|
|
445
|
+
const after = await current(sub.topic);
|
|
446
|
+
return { value, after, stable: sameCursor(before, after) };
|
|
447
|
+
});
|
|
448
|
+
live(connection);
|
|
449
|
+
if (!connection.subscriptions.has(subscription.id) || sub.validUntil <= Date.now())
|
|
450
|
+
throw new RealtimeError2("expired");
|
|
451
|
+
if (!result.stable)
|
|
452
|
+
push(connection, {
|
|
453
|
+
version: 1,
|
|
454
|
+
kind: "gap",
|
|
455
|
+
subscription: subscription.id,
|
|
456
|
+
reason: "snapshot-race",
|
|
457
|
+
cursor: cursorToken(result.after)
|
|
458
|
+
});
|
|
459
|
+
return { value: result.value, cursor: cursorToken(result.after), stable: result.stable };
|
|
460
|
+
},
|
|
461
|
+
[Symbol.asyncIterator]() {
|
|
462
|
+
if (connection.consumed)
|
|
463
|
+
throw new RealtimeError2("invalid-input");
|
|
464
|
+
connection.consumed = true;
|
|
465
|
+
return {
|
|
466
|
+
next: () => next(connection),
|
|
467
|
+
async return() {
|
|
468
|
+
closeConnection(connection);
|
|
469
|
+
return { done: true, value: undefined };
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
},
|
|
473
|
+
response() {
|
|
474
|
+
const iterator = api[Symbol.asyncIterator]();
|
|
475
|
+
const encoder = new TextEncoder;
|
|
476
|
+
let first = true;
|
|
477
|
+
const body = new ReadableStream({
|
|
478
|
+
async pull(controller) {
|
|
479
|
+
try {
|
|
480
|
+
const result = await iterator.next();
|
|
481
|
+
if (result.done) {
|
|
482
|
+
controller.close();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const event = result.value;
|
|
486
|
+
const prefix = first ? `retry: ${config.retryMs}
|
|
487
|
+
` : "";
|
|
488
|
+
first = false;
|
|
489
|
+
const id = event.cursor ? `id: ${event.cursor}
|
|
490
|
+
` : "";
|
|
491
|
+
controller.enqueue(encoder.encode(`${prefix}${id}event: ${event.kind}
|
|
492
|
+
data: ${JSON.stringify(event)}
|
|
493
|
+
|
|
494
|
+
`));
|
|
495
|
+
} catch {
|
|
496
|
+
closeConnection(connection);
|
|
497
|
+
controller.error(new RealtimeError2("closed"));
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
cancel() {
|
|
501
|
+
closeConnection(connection);
|
|
502
|
+
}
|
|
503
|
+
}, { highWaterMark: 0 });
|
|
504
|
+
return new Response(body, {
|
|
505
|
+
headers: {
|
|
506
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
507
|
+
"cache-control": "no-store",
|
|
508
|
+
"x-accel-buffering": "no"
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
close() {
|
|
513
|
+
closeConnection(connection);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
return api;
|
|
517
|
+
},
|
|
518
|
+
async publish(resource, type, data) {
|
|
519
|
+
if (typeof type !== "string" || !/^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/.test(type))
|
|
520
|
+
throw new RealtimeError2("invalid-input");
|
|
521
|
+
return publish(resource, { kind: "update", type, data });
|
|
522
|
+
},
|
|
523
|
+
async revokeResource(resource) {
|
|
524
|
+
const topic = resourceTopic(resource);
|
|
525
|
+
for (const sub of [...topics.get(topic) ?? []])
|
|
526
|
+
removeSub(sub, "revoked");
|
|
527
|
+
await publish(resource, { kind: "revoke" });
|
|
528
|
+
},
|
|
529
|
+
async deleteResource(resource) {
|
|
530
|
+
const topic = resourceTopic(resource);
|
|
531
|
+
for (const sub of [...topics.get(topic) ?? []])
|
|
532
|
+
removeSub(sub, "deleted");
|
|
533
|
+
await publish(resource, { kind: "deleted" });
|
|
534
|
+
},
|
|
535
|
+
revokeSubject(scope, subject) {
|
|
536
|
+
part(scope);
|
|
537
|
+
part(subject);
|
|
538
|
+
for (const connection of [...connections])
|
|
539
|
+
if (connection.identity.scope === scope && connection.identity.subject === subject)
|
|
540
|
+
closeConnection(connection, { version: 1, kind: "closed", reason: "revoked" });
|
|
541
|
+
},
|
|
542
|
+
stats() {
|
|
543
|
+
return {
|
|
544
|
+
connections: connections.size,
|
|
545
|
+
topics: topics.size,
|
|
546
|
+
pending,
|
|
547
|
+
subscriptions: [...connections].reduce((sum, c) => sum + c.subscriptions.size, 0)
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
close() {
|
|
551
|
+
return closing ??= (async () => {
|
|
552
|
+
stopped = true;
|
|
553
|
+
clearInterval(sweep);
|
|
554
|
+
lifetime.abort(new RealtimeError2("closed"));
|
|
555
|
+
for (const connection of [...connections])
|
|
556
|
+
closeConnection(connection, { version: 1, kind: "closed", reason: "shutdown" });
|
|
557
|
+
await provider.close();
|
|
558
|
+
})();
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export { defaults, resolveRealtimeConfig2, createRealtime2 };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// src/contracts.ts
|
|
2
|
+
class RealtimeError2 extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(`Realtime ${code}`);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "RealtimeError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/wire.ts
|
|
12
|
+
function part(value) {
|
|
13
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 256 || new TextEncoder().encode(value).byteLength > 256)
|
|
14
|
+
throw new RealtimeError2("invalid-input");
|
|
15
|
+
try {
|
|
16
|
+
return encodeURIComponent(value);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new RealtimeError2("invalid-input");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function resourceTopic(resource) {
|
|
22
|
+
return `resource/${part(resource.scope)}/${part(resource.type)}/${part(resource.id)}`;
|
|
23
|
+
}
|
|
24
|
+
function cursorToken(cursor, now = Date.now()) {
|
|
25
|
+
return `${cursor.generation}.${cursor.sequence}.${now}`;
|
|
26
|
+
}
|
|
27
|
+
function parseCursor(token) {
|
|
28
|
+
if (typeof token !== "string" || token.length > 160)
|
|
29
|
+
return;
|
|
30
|
+
const match = /^([A-Za-z0-9-]{1,64})\.(\d{1,16})\.(\d{1,16})$/.exec(token);
|
|
31
|
+
if (!match)
|
|
32
|
+
return;
|
|
33
|
+
const sequence = Number(match[2]);
|
|
34
|
+
const issuedAt = Number(match[3]);
|
|
35
|
+
if (!Number.isSafeInteger(sequence) || !Number.isSafeInteger(issuedAt))
|
|
36
|
+
return;
|
|
37
|
+
return { generation: match[1], sequence, issuedAt };
|
|
38
|
+
}
|
|
39
|
+
function sameCursor(a, b) {
|
|
40
|
+
return a.generation === b.generation && a.sequence === b.sequence;
|
|
41
|
+
}
|
|
42
|
+
function serialize(value, maxBytes) {
|
|
43
|
+
const active = new Set;
|
|
44
|
+
let nodes = 0;
|
|
45
|
+
const visit = (input, depth) => {
|
|
46
|
+
if (++nodes > 1e4 || depth > 32)
|
|
47
|
+
throw new RealtimeError2("payload");
|
|
48
|
+
if (input === null || typeof input === "string" || typeof input === "boolean")
|
|
49
|
+
return;
|
|
50
|
+
if (typeof input === "number" && Number.isFinite(input))
|
|
51
|
+
return;
|
|
52
|
+
if (typeof input !== "object" || input === null || active.has(input))
|
|
53
|
+
throw new RealtimeError2("payload");
|
|
54
|
+
const proto = Object.getPrototypeOf(input);
|
|
55
|
+
if (Array.isArray(input) ? proto !== Array.prototype : proto !== Object.prototype && proto !== null)
|
|
56
|
+
throw new RealtimeError2("payload");
|
|
57
|
+
active.add(input);
|
|
58
|
+
if (Object.getOwnPropertySymbols(input).length)
|
|
59
|
+
throw new RealtimeError2("payload");
|
|
60
|
+
const keys = Object.keys(input);
|
|
61
|
+
const ownNames = Object.getOwnPropertyNames(input);
|
|
62
|
+
if (ownNames.length !== keys.length + (Array.isArray(input) ? 1 : 0))
|
|
63
|
+
throw new RealtimeError2("payload");
|
|
64
|
+
if (Array.isArray(input) && (keys.length !== input.length || keys.some((key, i) => key !== String(i))))
|
|
65
|
+
throw new RealtimeError2("payload");
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
68
|
+
if (!("value" in descriptor))
|
|
69
|
+
throw new RealtimeError2("payload");
|
|
70
|
+
visit(descriptor.value, depth + 1);
|
|
71
|
+
}
|
|
72
|
+
active.delete(input);
|
|
73
|
+
};
|
|
74
|
+
visit(value, 0);
|
|
75
|
+
const text = JSON.stringify(value);
|
|
76
|
+
if (new TextEncoder().encode(text).byteLength > maxBytes)
|
|
77
|
+
throw new RealtimeError2("payload");
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export { RealtimeError2, part, resourceTopic, cursorToken, parseCursor, sameCursor, serialize };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type * from "./contracts";
|
|
2
|
+
export { RealtimeError } from "./contracts";
|
|
3
|
+
export { resolveRealtimeConfig } from "./config";
|
|
4
|
+
import { type Realtime, type RealtimeOptions } from "./contracts";
|
|
5
|
+
export declare function createRealtime<P>(options: RealtimeOptions<P>): Promise<Realtime<P>>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveRealtimeConfig2,
|
|
3
|
+
createRealtime2
|
|
4
|
+
} from "./index-6fg21p7s.js";
|
|
5
|
+
import {
|
|
6
|
+
RealtimeError2
|
|
7
|
+
} from "./index-htkqerky.js";
|
|
8
|
+
export {
|
|
9
|
+
RealtimeError2 as RealtimeError,
|
|
10
|
+
createRealtime2 as createRealtime,
|
|
11
|
+
resolveRealtimeConfig2 as resolveRealtimeConfig
|
|
12
|
+
};
|