@voltro/plugin-presence 0.33.0 → 0.34.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 +1801 -0
- package/README.md +1 -1
- package/dist/index.d.ts +72 -71
- package/dist/index.js +132 -144
- package/dist/rpc.d.ts +16 -1
- package/dist/rpc.js +10 -10
- package/dist/web.d.ts +0 -1
- package/package.json +4 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# @voltro/plugin-presence
|
|
4
4
|
|
|
5
|
-
**Ephemeral realtime presence — who is online in a channel, with heartbeat + live roster + per-member metadata (cursor, status).
|
|
5
|
+
**Ephemeral realtime presence — who is online in a channel, with heartbeat + live roster + per-member metadata (cursor, status). Held in memory, owner-partitioned; cross-instance with @voltro/plugin-broadcast.**
|
|
6
6
|
|
|
7
7
|
[📖 Documentation](https://docs.voltro.dev/docs/plugins/presence) · [voltro.dev](https://voltro.dev) · [Voltro Cloud](https://voltro.cloud)
|
|
8
8
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { ColumnBuilder } from '@voltro/database';
|
|
2
1
|
import { Effect } from 'effect';
|
|
3
|
-
import {
|
|
2
|
+
import { ReactivityChannel } from '@voltro/protocol';
|
|
4
3
|
import { Schema } from 'effect';
|
|
5
|
-
import { Table } from '@voltro/database';
|
|
6
4
|
import { VoltroPlugin } from '@voltro/protocol';
|
|
7
5
|
|
|
8
6
|
/**
|
|
@@ -25,18 +23,18 @@ export declare interface AttachPresenceBusOptions {
|
|
|
25
23
|
};
|
|
26
24
|
/** The namespaced presence channel, from the framework's one resolver. */
|
|
27
25
|
readonly channel?: string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Called when a REMOTE event moved this replica's roster — a peer's delta, or
|
|
28
|
+
* a peer's departure taking its partition with it.
|
|
29
|
+
*
|
|
30
|
+
* Only for the remote paths. A local heartbeat's push is the caller's, because
|
|
31
|
+
* only the caller knows whether the write it just made changed anything;
|
|
32
|
+
* routing it through here as well would give one fact two owners.
|
|
33
|
+
*/
|
|
34
|
+
readonly onRosterChanged?: (() => void) | undefined;
|
|
28
35
|
readonly now?: () => number;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
/** The currently-online members of a channel, newest heartbeat first. */
|
|
32
|
-
export declare const filterOnline: (entries: ReadonlyArray<PresenceEntry>, now: number, timeoutMs: number) => ReadonlyArray<PresenceEntry>;
|
|
33
|
-
|
|
34
|
-
/** A member is online if its last heartbeat is within the timeout window. */
|
|
35
|
-
export declare const isOnline: (lastSeen: number, now: number, timeoutMs: number) => boolean;
|
|
36
|
-
|
|
37
|
-
/** In-memory presence store (tests + single-process dev). */
|
|
38
|
-
export declare const memoryPresenceStore: () => PresenceStore;
|
|
39
|
-
|
|
40
38
|
/**
|
|
41
39
|
* Its own channel, for the same reason events are not on the change channel —
|
|
42
40
|
* and namespaced by the framework, which passes the resolved value in.
|
|
@@ -78,16 +76,6 @@ export declare type PresenceDelta = {
|
|
|
78
76
|
readonly key: string;
|
|
79
77
|
};
|
|
80
78
|
|
|
81
|
-
export declare interface PresenceEntry {
|
|
82
|
-
readonly channel: string;
|
|
83
|
-
/** Stable presence key — the subject id, or a client-supplied id for anon. */
|
|
84
|
-
readonly key: string;
|
|
85
|
-
readonly tenantId: string | null;
|
|
86
|
-
readonly meta: Record<string, unknown> | null;
|
|
87
|
-
/** epoch ms of the last heartbeat. */
|
|
88
|
-
readonly lastSeen: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
79
|
/** The caller has no presence identity — an anonymous subject (no subject id)
|
|
92
80
|
* that also passed no explicit `key`. There's nothing stable to register a
|
|
93
81
|
* heartbeat under, so the route fails typed instead of silently no-opping.
|
|
@@ -132,20 +120,37 @@ export declare interface PresencePluginOptions {
|
|
|
132
120
|
* the default is the right relationship for almost every room.
|
|
133
121
|
*/
|
|
134
122
|
readonly sweepIntervalMs?: number;
|
|
123
|
+
/**
|
|
124
|
+
* Namespace for this plugin's rpc tags. Default `presence`.
|
|
125
|
+
*
|
|
126
|
+
* Set it when your app already publishes under that name — an exact tag
|
|
127
|
+
* collision is fatal at codegen, and this is the way out. Orthogonal to
|
|
128
|
+
* `name` below: `alias` REPLACES the namespace, `name` distinguishes two
|
|
129
|
+
* installations within it.
|
|
130
|
+
*/
|
|
131
|
+
readonly alias?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Discriminator for a SECOND installation of this plugin, when one app runs
|
|
134
|
+
* two (`@voltro/plugin-presence#analytics`). Not a rename — for that use
|
|
135
|
+
* `alias`.
|
|
136
|
+
*/
|
|
135
137
|
readonly name?: string;
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
/**
|
|
141
|
+
* The roster's reactivity channel — presence's push target.
|
|
142
|
+
*
|
|
143
|
+
* Declared HERE, in the browser-safe descriptor module, for two reasons. It is
|
|
144
|
+
* the `source:` of `listDescriptor`, so the client descriptor and the server
|
|
145
|
+
* route now carry the SAME value instead of the server adding one the client
|
|
146
|
+
* never saw. And the server's `publishReactivity` imports this exact object, so
|
|
147
|
+
* the thing that publishes and the thing that subscribes cannot name two
|
|
148
|
+
* different keys — the failure a pair of strings makes possible and silent.
|
|
149
|
+
*
|
|
150
|
+
* It replaces `_voltro_presence`, a table that was declared and deliberately
|
|
151
|
+
* never written purely to own this name.
|
|
152
|
+
*/
|
|
153
|
+
export declare const presenceRoster: ReactivityChannel;
|
|
149
154
|
|
|
150
155
|
/** The presence store isn't bound — `bindDataStore` never ran. This is a boot/
|
|
151
156
|
* wiring bug (the plugin wasn't installed on an app with a DataStore), not a
|
|
@@ -157,37 +162,6 @@ declare const PresenceStoreUnavailable_base: Schema.TaggedErrorClass<PresenceSto
|
|
|
157
162
|
readonly _tag: Schema.tag<"PresenceStoreUnavailable">;
|
|
158
163
|
}>;
|
|
159
164
|
|
|
160
|
-
/**
|
|
161
|
-
* The presence table — DECLARED, and deliberately NEVER WRITTEN.
|
|
162
|
-
*
|
|
163
|
-
* Presence state lives in the owner-partitioned `PresenceTracker`, in memory,
|
|
164
|
-
* announced between replicas over the broadcast channel. No row is inserted or
|
|
165
|
-
* updated any more: a heartbeat used to rewrite one row per client every 15
|
|
166
|
-
* seconds, which is a lot of write amplification for a datum that is meaningless
|
|
167
|
-
* 30 seconds later.
|
|
168
|
-
*
|
|
169
|
-
* The declaration survives because the name is the REACTIVITY KEY. `presence.list`
|
|
170
|
-
* declares `source: '_voltro_presence'`, and the framework routes change events
|
|
171
|
-
* by table name — so the plugin injects a synthetic change on that name whenever
|
|
172
|
-
* the tracker moves, and every subscribed client is pushed a fresh roster
|
|
173
|
-
* through the path it already used. Removing the declaration would make the
|
|
174
|
-
* `source` name resolve to nothing, which the boot audit reports (correctly) and
|
|
175
|
-
* which would silently stop every roster from updating.
|
|
176
|
-
*
|
|
177
|
-
* It therefore stays as an empty table. That is a real cost — one unused table
|
|
178
|
-
* in every user's database — accepted over the alternatives: a `source` that
|
|
179
|
-
* names nothing (invisible breakage, and an audit exemption that would rot), or
|
|
180
|
-
* a second client-side subscription concept just for presence.
|
|
181
|
-
*/
|
|
182
|
-
export declare const presenceTable: Table<"_voltro_presence", FieldDefinitions<{
|
|
183
|
-
readonly id: ColumnBuilder<string, "id", boolean>;
|
|
184
|
-
readonly channel: ColumnBuilder<string, "text", boolean>;
|
|
185
|
-
readonly key: ColumnBuilder<string, "text", boolean>;
|
|
186
|
-
readonly tenantId: ColumnBuilder<string | null, "text", boolean>;
|
|
187
|
-
readonly meta: ColumnBuilder<unknown, "json", boolean>;
|
|
188
|
-
readonly lastSeen: ColumnBuilder<Date, "timestamp", true>;
|
|
189
|
-
}>, true, "byPresenceChannel">;
|
|
190
|
-
|
|
191
165
|
/**
|
|
192
166
|
* The merged presence view this instance can see.
|
|
193
167
|
*
|
|
@@ -212,29 +186,36 @@ export declare class PresenceTracker {
|
|
|
212
186
|
*
|
|
213
187
|
* Returns the delta to broadcast, so the caller cannot forget to — a local
|
|
214
188
|
* write that is not announced is a member the other replicas never see, and
|
|
215
|
-
* nothing would report it.
|
|
189
|
+
* nothing would report it. It returns `changed` beside it for the same kind of
|
|
190
|
+
* reason: a caller that has to derive "did this move the roster" would derive
|
|
191
|
+
* it from the delta, which cannot answer the question.
|
|
216
192
|
*/
|
|
217
193
|
track(input: {
|
|
218
194
|
readonly channel: string;
|
|
219
195
|
readonly tenantId: string | null;
|
|
220
196
|
readonly key: string;
|
|
221
197
|
readonly meta?: Record<string, unknown> | null;
|
|
222
|
-
}):
|
|
198
|
+
}): PresenceWrite;
|
|
223
199
|
/** A client of THIS instance left. */
|
|
224
200
|
untrack(input: {
|
|
225
201
|
readonly channel: string;
|
|
226
202
|
readonly tenantId: string | null;
|
|
227
203
|
readonly key: string;
|
|
228
|
-
}):
|
|
204
|
+
}): PresenceWrite;
|
|
229
205
|
/**
|
|
230
|
-
* Apply a delta from ANOTHER instance.
|
|
206
|
+
* Apply a delta from ANOTHER instance. Returns whether the roster moved.
|
|
231
207
|
*
|
|
232
208
|
* Our own owner id is ignored: the local write already happened synchronously
|
|
233
209
|
* in `track`, and re-applying the echo would be harmless today but would make
|
|
234
210
|
* the local map depend on the broker — so a broker outage would silently stop
|
|
235
211
|
* this instance from seeing its OWN clients.
|
|
212
|
+
*
|
|
213
|
+
* The return value is what lets the bus push a remote join to LOCAL
|
|
214
|
+
* subscribers. Until it existed, nothing did: a member who joined on replica A
|
|
215
|
+
* appeared on replica B's screens only when one of B's own clients happened to
|
|
216
|
+
* heartbeat, and the unconditional push on that heartbeat is what hid it.
|
|
236
217
|
*/
|
|
237
|
-
apply(owner: string, delta: PresenceDelta):
|
|
218
|
+
apply(owner: string, delta: PresenceDelta): boolean;
|
|
238
219
|
/**
|
|
239
220
|
* An instance is gone — drop everything it owned, in one operation.
|
|
240
221
|
*
|
|
@@ -308,8 +289,28 @@ export declare interface PresenceTransport {
|
|
|
308
289
|
readonly subscribe: (channel: string, handler: (payload: string) => void) => Effect.Effect<() => void, unknown>;
|
|
309
290
|
}
|
|
310
291
|
|
|
311
|
-
/**
|
|
312
|
-
|
|
292
|
+
/**
|
|
293
|
+
* A local write, and whether it MOVED the roster.
|
|
294
|
+
*
|
|
295
|
+
* The second field is the whole reason this is not just a `PresenceDelta`. A
|
|
296
|
+
* heartbeat arrives every 15 seconds per client and almost never changes
|
|
297
|
+
* anything a subscriber can observe — the member was already there with the same
|
|
298
|
+
* metadata. Pushing on it anyway made the cost quadratic: N clients heartbeating
|
|
299
|
+
* into a room woke all N subscribers each time, N² wake-ups per interval, for a
|
|
300
|
+
* roster that had not moved.
|
|
301
|
+
*
|
|
302
|
+
* `changed` is exact rather than approximate because `lastSeen` is no longer on
|
|
303
|
+
* the wire (see `rpc.ts`). It is a heartbeat's ONLY effect in the steady state,
|
|
304
|
+
* so while it was observable, "did anything change" could only ever be answered
|
|
305
|
+
* "yes".
|
|
306
|
+
*/
|
|
307
|
+
export declare interface PresenceWrite {
|
|
308
|
+
/** The delta to broadcast. Always present — a peer must learn of a heartbeat
|
|
309
|
+
* even when the roster did not move, or its own sweep will drop the member. */
|
|
310
|
+
readonly delta: PresenceDelta;
|
|
311
|
+
/** Whether a local subscriber would see a different roster. */
|
|
312
|
+
readonly changed: boolean;
|
|
313
|
+
}
|
|
313
314
|
|
|
314
315
|
/** One member as the roster reports it, with the owner it belongs to. */
|
|
315
316
|
export declare interface TrackedMember extends PresenceMember {
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { PresenceKeyMissing as e, PresenceStoreUnavailable as t } from "./errors.js";
|
|
2
|
-
import { heartbeatDescriptor as n, leaveDescriptor as r, listDescriptor as i,
|
|
3
|
-
import { Effect as
|
|
4
|
-
import {
|
|
5
|
-
import { definePlugin as f } from "@voltro/protocol";
|
|
2
|
+
import { heartbeatDescriptor as n, leaveDescriptor as r, listDescriptor as i, presenceRoster as a, presenceRpcClientImports as o } from "./rpc.js";
|
|
3
|
+
import { Effect as s } from "effect";
|
|
4
|
+
import { definePlugin as c, pluginInstanceName as l, publishReactivity as u } from "@voltro/protocol";
|
|
6
5
|
//#region src/tracker.ts
|
|
7
|
-
var
|
|
6
|
+
var d = (e, t) => `${e ?? "~"}::${t}`, f = (e) => e === void 0 ? "undefined" : typeof e != "object" || !e ? JSON.stringify(e) ?? "undefined" : Array.isArray(e) ? `[${e.map(f).join(",")}]` : `{${Object.entries(e).sort(([e], [t]) => e < t ? -1 : +(e > t)).map(([e, t]) => `${JSON.stringify(e)}:${f(t)}`).join(",")}}`, p = (e, t) => e === t || e !== null && t !== null && f(e) === f(t), m = class {
|
|
8
7
|
partitions = /* @__PURE__ */ new Map();
|
|
9
8
|
selfId;
|
|
10
9
|
now;
|
|
@@ -17,7 +16,7 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
|
|
|
17
16
|
branch(e, t, n) {
|
|
18
17
|
let r = this.partitions.get(e);
|
|
19
18
|
r === void 0 && (r = /* @__PURE__ */ new Map(), this.partitions.set(e, r));
|
|
20
|
-
let i =
|
|
19
|
+
let i = d(t, n), a = r.get(i);
|
|
21
20
|
return a === void 0 && (a = {
|
|
22
21
|
tenantId: t,
|
|
23
22
|
channel: n,
|
|
@@ -25,40 +24,45 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
|
|
|
25
24
|
}, r.set(i, a)), a;
|
|
26
25
|
}
|
|
27
26
|
track(e) {
|
|
28
|
-
let t = this.now(), n = e.meta ?? null;
|
|
29
|
-
return
|
|
27
|
+
let t = this.now(), n = e.meta ?? null, { members: r } = this.branch(this.selfId, e.tenantId, e.channel), i = r.get(e.key);
|
|
28
|
+
return r.set(e.key, {
|
|
30
29
|
key: e.key,
|
|
31
30
|
meta: n,
|
|
32
31
|
lastSeen: t
|
|
33
32
|
}), {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
delta: {
|
|
34
|
+
op: "set",
|
|
35
|
+
channel: e.channel,
|
|
36
|
+
tenantId: e.tenantId,
|
|
37
|
+
key: e.key,
|
|
38
|
+
meta: n,
|
|
39
|
+
lastSeen: t
|
|
40
|
+
},
|
|
41
|
+
changed: i === void 0 || !p(i.meta, n)
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
untrack(e) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
let t = this.branch(this.selfId, e.tenantId, e.channel).members.delete(e.key);
|
|
46
|
+
return {
|
|
47
|
+
delta: {
|
|
48
|
+
op: "remove",
|
|
49
|
+
channel: e.channel,
|
|
50
|
+
tenantId: e.tenantId,
|
|
51
|
+
key: e.key
|
|
52
|
+
},
|
|
53
|
+
changed: t
|
|
48
54
|
};
|
|
49
55
|
}
|
|
50
56
|
apply(e, t) {
|
|
51
|
-
if (e === this.selfId) return;
|
|
57
|
+
if (e === this.selfId) return !1;
|
|
52
58
|
let { members: n } = this.branch(e, t.tenantId, t.channel);
|
|
53
|
-
if (t.op === "remove")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
n.set(t.key, {
|
|
59
|
+
if (t.op === "remove") return n.delete(t.key);
|
|
60
|
+
let r = n.get(t.key);
|
|
61
|
+
return n.set(t.key, {
|
|
58
62
|
key: t.key,
|
|
59
63
|
meta: t.meta,
|
|
60
64
|
lastSeen: t.lastSeen
|
|
61
|
-
});
|
|
65
|
+
}), r === void 0 || !p(r.meta, t.meta);
|
|
62
66
|
}
|
|
63
67
|
dropOwner(e) {
|
|
64
68
|
if (e === this.selfId) return 0;
|
|
@@ -94,7 +98,7 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
|
|
|
94
98
|
return e;
|
|
95
99
|
}
|
|
96
100
|
roster(e, t) {
|
|
97
|
-
let n =
|
|
101
|
+
let n = d(t, e), r = [];
|
|
98
102
|
for (let [i, a] of this.partitions) {
|
|
99
103
|
let o = a.get(n);
|
|
100
104
|
if (o !== void 0) for (let n of o.members.values()) r.push({
|
|
@@ -123,27 +127,31 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
|
|
|
123
127
|
let t = new m({
|
|
124
128
|
instanceId: e.instanceId,
|
|
125
129
|
...e.now === void 0 ? {} : { now: e.now }
|
|
126
|
-
}), n = e.logger, r =
|
|
130
|
+
}), n = e.logger, r = () => {
|
|
131
|
+
try {
|
|
132
|
+
e.onRosterChanged?.();
|
|
133
|
+
} catch {}
|
|
134
|
+
}, i = e.membership.onChange((e) => {
|
|
127
135
|
if (e.kind === "joined") {
|
|
128
|
-
|
|
136
|
+
l();
|
|
129
137
|
return;
|
|
130
138
|
}
|
|
131
|
-
let
|
|
132
|
-
|
|
139
|
+
let i = t.dropOwner(e.instanceId);
|
|
140
|
+
i > 0 && (n?.warn("presence: dropped members of a departed instance", {
|
|
133
141
|
instanceId: e.instanceId,
|
|
134
|
-
members:
|
|
142
|
+
members: i,
|
|
135
143
|
reason: e.kind
|
|
136
|
-
}), e.kind === "restarted" &&
|
|
137
|
-
}),
|
|
138
|
-
|
|
139
|
-
},
|
|
144
|
+
}), r()), e.kind === "restarted" && l();
|
|
145
|
+
}), a = e.provider, o = e.channel ?? "voltro:presence", c = (e) => {
|
|
146
|
+
a !== void 0 && s.runPromise(a.publish(o, JSON.stringify(e))).catch(() => {});
|
|
147
|
+
}, l = () => {
|
|
140
148
|
let n = t.ownSnapshot();
|
|
141
|
-
n.length !== 0 &&
|
|
149
|
+
n.length !== 0 && c({
|
|
142
150
|
owner: e.instanceId,
|
|
143
151
|
sync: n
|
|
144
152
|
});
|
|
145
|
-
},
|
|
146
|
-
return
|
|
153
|
+
}, u;
|
|
154
|
+
return a !== void 0 && s.runPromise(a.subscribe(o, (e) => {
|
|
147
155
|
let n;
|
|
148
156
|
try {
|
|
149
157
|
n = JSON.parse(e);
|
|
@@ -151,147 +159,127 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
|
|
|
151
159
|
return;
|
|
152
160
|
}
|
|
153
161
|
if (_(n)) {
|
|
154
|
-
|
|
162
|
+
let e = !1;
|
|
163
|
+
for (let r of n.sync) e = t.apply(n.owner, r) || e;
|
|
164
|
+
e && r();
|
|
155
165
|
return;
|
|
156
166
|
}
|
|
157
|
-
g(n) && t.apply(n.owner, n.delta);
|
|
167
|
+
g(n) && t.apply(n.owner, n.delta) && r();
|
|
158
168
|
})).then((e) => {
|
|
159
|
-
|
|
169
|
+
u = e;
|
|
160
170
|
}, (e) => {
|
|
161
171
|
n?.warn("presence: could not subscribe — this instance will only see its own members", { cause: e });
|
|
162
172
|
}), {
|
|
163
173
|
tracker: t,
|
|
164
|
-
hasTransport:
|
|
174
|
+
hasTransport: a !== void 0,
|
|
165
175
|
announce: (t) => {
|
|
166
|
-
|
|
176
|
+
c({
|
|
167
177
|
owner: e.instanceId,
|
|
168
178
|
delta: t
|
|
169
179
|
});
|
|
170
180
|
},
|
|
171
181
|
detach: () => {
|
|
172
|
-
|
|
182
|
+
i(), u?.();
|
|
173
183
|
}
|
|
174
184
|
};
|
|
175
|
-
}, y =
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
upsert: async (n) => {
|
|
179
|
-
e.set(t(n.channel, n.key), n);
|
|
180
|
-
},
|
|
181
|
-
list: async (t, n) => [...e.values()].filter((e) => e.channel === t && e.tenantId === n),
|
|
182
|
-
remove: async (n, r) => {
|
|
183
|
-
e.delete(t(n, r));
|
|
184
|
-
},
|
|
185
|
-
sweep: async (t) => {
|
|
186
|
-
let n = 0;
|
|
187
|
-
for (let [r, i] of e) i.lastSeen < t && (e.delete(r), n++);
|
|
188
|
-
return n;
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
}, C = "_voltro_presence", w = l(C, {
|
|
192
|
-
id: s({ prefix: "pres" }),
|
|
193
|
-
channel: u(),
|
|
194
|
-
key: u(),
|
|
195
|
-
tenantId: u().nullable(),
|
|
196
|
-
meta: c().nullable(),
|
|
197
|
-
lastSeen: d().default("now")
|
|
198
|
-
}).unique(["channel", "key"]).index("byPresenceChannel", ["channel", "lastSeen"]), T = (s = {}) => {
|
|
199
|
-
if (s.timeoutMs !== void 0 && s.timeoutMs < 1e3) throw Error(`presencePlugin: \`timeoutMs\` is ${s.timeoutMs}, which expires every member before it can heartbeat again.
|
|
185
|
+
}, y = "@voltro/plugin-presence", b = (d = {}) => {
|
|
186
|
+
if (d.timeoutMs !== void 0 && d.timeoutMs < 1e3) throw Error(`presencePlugin: \`timeoutMs\` is ${d.timeoutMs}, which expires every member before it can heartbeat again.
|
|
200
187
|
A member is online for \`timeoutMs\` after its last heartbeat, and \`usePresence\` beats every 15s by default — so this must be LARGER than your client's
|
|
201
188
|
\`heartbeatMs\`, comfortably. The default pair is 30s server / 15s client.
|
|
202
189
|
There is no "never expire" spelling: omit \`timeoutMs\` for the default.`);
|
|
203
|
-
let
|
|
204
|
-
|
|
205
|
-
|
|
190
|
+
let f, p = d.timeoutMs ?? 3e4, m = Math.max(1e3, Math.floor(d.sweepIntervalMs ?? p / 3)), h = l({
|
|
191
|
+
base: y,
|
|
192
|
+
alias: d.alias,
|
|
193
|
+
instance: d.name
|
|
194
|
+
}), g, _, b, x = () => {
|
|
195
|
+
b !== void 0 || g === void 0 || (b = setInterval(() => {
|
|
196
|
+
let e = g;
|
|
206
197
|
if (e === void 0) return;
|
|
207
|
-
let t = e.tracker.sweep(
|
|
198
|
+
let t = e.tracker.sweep(p);
|
|
208
199
|
if (t.length !== 0) {
|
|
209
200
|
for (let n of t) e.announce(n);
|
|
210
|
-
|
|
201
|
+
w();
|
|
211
202
|
}
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
});
|
|
220
|
-
}, x = [
|
|
221
|
-
{
|
|
222
|
-
...n,
|
|
223
|
-
description: "Register/refresh the caller's presence in a channel.",
|
|
224
|
-
execute: (n, r) => o.gen(function* () {
|
|
225
|
-
let i = n;
|
|
226
|
-
if (!p) return yield* o.fail(new t({}));
|
|
227
|
-
let a = _(r, i.key);
|
|
228
|
-
return a === null ? yield* o.fail(new e({ channel: i.channel })) : (p.announce(p.tracker.track({
|
|
229
|
-
channel: i.channel,
|
|
230
|
-
tenantId: y(r),
|
|
231
|
-
key: a,
|
|
232
|
-
...i.meta === void 0 ? {} : { meta: i.meta }
|
|
233
|
-
})), b(), { ok: !0 });
|
|
234
|
-
})
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
...i,
|
|
238
|
-
description: "The online roster of a channel (heartbeat-fresh members). Push-driven — updates live.",
|
|
239
|
-
source: C,
|
|
240
|
-
execute: (e, n) => o.gen(function* () {
|
|
241
|
-
return p ? p.tracker.roster(e.channel, y(n)).map((e) => ({
|
|
242
|
-
key: e.key,
|
|
243
|
-
meta: e.meta,
|
|
244
|
-
lastSeen: e.lastSeen
|
|
245
|
-
})) : yield* o.fail(new t({}));
|
|
246
|
-
})
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
...r,
|
|
250
|
-
description: "Remove the caller's presence from a channel.",
|
|
251
|
-
execute: (n, r) => o.gen(function* () {
|
|
252
|
-
let i = n;
|
|
253
|
-
if (!p) return yield* o.fail(new t({}));
|
|
254
|
-
let a = _(r, i.key);
|
|
255
|
-
return a === null ? yield* o.fail(new e({ channel: i.channel })) : (p.announce(p.tracker.untrack({
|
|
256
|
-
channel: i.channel,
|
|
257
|
-
tenantId: y(r),
|
|
258
|
-
key: a
|
|
259
|
-
})), b(), { ok: !0 });
|
|
260
|
-
})
|
|
261
|
-
}
|
|
262
|
-
];
|
|
263
|
-
return f({
|
|
264
|
-
name: d,
|
|
203
|
+
}, m), b.unref?.());
|
|
204
|
+
}, S = (e, t) => typeof t == "string" && t.length > 0 ? t : e.request.subject?.id ?? null, C = (e) => e.request.subject?.tenantId ?? null, w = () => {
|
|
205
|
+
u(_, a);
|
|
206
|
+
};
|
|
207
|
+
return c({
|
|
208
|
+
name: h,
|
|
209
|
+
baseName: y,
|
|
265
210
|
description: "Ephemeral realtime presence — heartbeat roster per channel.",
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
211
|
+
routes: [
|
|
212
|
+
{
|
|
213
|
+
...n,
|
|
214
|
+
description: "Register/refresh the caller's presence in a channel.",
|
|
215
|
+
execute: (n, r) => s.gen(function* () {
|
|
216
|
+
let i = n;
|
|
217
|
+
if (!g) return yield* s.fail(new t({}));
|
|
218
|
+
let a = S(r, i.key);
|
|
219
|
+
if (a === null) return yield* s.fail(new e({ channel: i.channel }));
|
|
220
|
+
let o = g.tracker.track({
|
|
221
|
+
channel: i.channel,
|
|
222
|
+
tenantId: C(r),
|
|
223
|
+
key: a,
|
|
224
|
+
...i.meta === void 0 ? {} : { meta: i.meta }
|
|
225
|
+
});
|
|
226
|
+
return g.announce(o.delta), o.changed && w(), { ok: !0 };
|
|
227
|
+
})
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
...i,
|
|
231
|
+
description: "The online roster of a channel (heartbeat-fresh members). Push-driven — updates live.",
|
|
232
|
+
execute: (e, n) => s.gen(function* () {
|
|
233
|
+
return g ? g.tracker.roster(e.channel, C(n)).map((e) => ({
|
|
234
|
+
key: e.key,
|
|
235
|
+
meta: e.meta
|
|
236
|
+
})) : yield* s.fail(new t({}));
|
|
237
|
+
})
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
...r,
|
|
241
|
+
description: "Remove the caller's presence from a channel.",
|
|
242
|
+
execute: (n, r) => s.gen(function* () {
|
|
243
|
+
let i = n;
|
|
244
|
+
if (!g) return yield* s.fail(new t({}));
|
|
245
|
+
let a = S(r, i.key);
|
|
246
|
+
if (a === null) return yield* s.fail(new e({ channel: i.channel }));
|
|
247
|
+
let o = g.tracker.untrack({
|
|
248
|
+
channel: i.channel,
|
|
249
|
+
tenantId: C(r),
|
|
250
|
+
key: a
|
|
251
|
+
});
|
|
252
|
+
return g.announce(o.delta), o.changed && w(), { ok: !0 };
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
],
|
|
256
|
+
rpcClientDescriptors: o,
|
|
270
257
|
bindDataStore: (e, t) => {
|
|
271
|
-
|
|
258
|
+
_ = e;
|
|
272
259
|
let n = t;
|
|
273
|
-
|
|
260
|
+
g = v({
|
|
274
261
|
instanceId: n?.instanceId ?? `presence-${process.pid}`,
|
|
275
262
|
membership: n?.membership ?? { onChange: () => () => {} },
|
|
276
263
|
...n?.broadcast === void 0 ? {} : { provider: n.broadcast },
|
|
277
|
-
...n?.broadcastChannels?.presence === void 0 ? {} : { channel: n.broadcastChannels.presence }
|
|
264
|
+
...n?.broadcastChannels?.presence === void 0 ? {} : { channel: n.broadcastChannels.presence },
|
|
265
|
+
onRosterChanged: w
|
|
278
266
|
});
|
|
279
267
|
},
|
|
280
|
-
onActivate: (e) =>
|
|
281
|
-
|
|
282
|
-
timeoutMs:
|
|
283
|
-
sweepEveryMs:
|
|
268
|
+
onActivate: (e) => s.sync(() => {
|
|
269
|
+
x(), e.logger.info("presence active", {
|
|
270
|
+
timeoutMs: p,
|
|
271
|
+
sweepEveryMs: m
|
|
284
272
|
});
|
|
285
273
|
let t = Number(process.env.VOLTRO_PRESENCE_BROKER_GRACE_MS ?? 5e3), n = setTimeout(() => {
|
|
286
|
-
let t =
|
|
274
|
+
let t = g?.hasTransport === !0;
|
|
287
275
|
e.logger.info("presence transport", { crossInstance: t }), !t && e.logger.warn("presence: no broadcast provider — each replica sees only the clients CONNECTED TO IT. Correct for a single instance; on more than one, every screen shows a fraction of the room and nothing reports it. Add @voltro/plugin-broadcast (redis:// or nats://) to share the roster.");
|
|
288
276
|
}, t);
|
|
289
|
-
n.unref?.(),
|
|
277
|
+
n.unref?.(), f = n;
|
|
290
278
|
}),
|
|
291
|
-
onDeactivate: () =>
|
|
292
|
-
|
|
279
|
+
onDeactivate: () => s.sync(() => {
|
|
280
|
+
f !== void 0 && (clearTimeout(f), f = void 0), b !== void 0 && (clearInterval(b), b = void 0), g?.detach(), g = void 0, _ = void 0;
|
|
293
281
|
})
|
|
294
282
|
});
|
|
295
283
|
};
|
|
296
284
|
//#endregion
|
|
297
|
-
export { h as PRESENCE_BROADCAST_CHANNEL, e as PresenceKeyMissing, t as PresenceStoreUnavailable, m as PresenceTracker, v as attachPresenceBus, b as
|
|
285
|
+
export { h as PRESENCE_BROADCAST_CHANNEL, e as PresenceKeyMissing, t as PresenceStoreUnavailable, m as PresenceTracker, v as attachPresenceBus, b as presencePlugin, a as presenceRoster };
|
package/dist/rpc.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MutationProcedureDescriptor } from '@voltro/protocol';
|
|
2
2
|
import { PluginRpcClientDescriptor } from '@voltro/protocol';
|
|
3
3
|
import { QueryProcedureDescriptor } from '@voltro/protocol';
|
|
4
|
+
import { ReactivityChannel } from '@voltro/protocol';
|
|
4
5
|
import { Schema } from 'effect';
|
|
5
6
|
|
|
6
7
|
export declare const heartbeatDescriptor: MutationProcedureDescriptor<"presence.heartbeat", Schema.Struct<{
|
|
@@ -23,7 +24,6 @@ export declare const listDescriptor: QueryProcedureDescriptor<"presence.list", S
|
|
|
23
24
|
}>, Schema.Array$<Schema.Struct<{
|
|
24
25
|
key: typeof Schema.String;
|
|
25
26
|
meta: Schema.NullOr<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
26
|
-
lastSeen: typeof Schema.Number;
|
|
27
27
|
}>>, typeof PresenceStoreUnavailable>;
|
|
28
28
|
|
|
29
29
|
/** The caller has no presence identity — an anonymous subject (no subject id)
|
|
@@ -39,6 +39,21 @@ declare const PresenceKeyMissing_base: Schema.TaggedErrorClass<PresenceKeyMissin
|
|
|
39
39
|
channel: typeof Schema.String;
|
|
40
40
|
}>;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The roster's reactivity channel — presence's push target.
|
|
44
|
+
*
|
|
45
|
+
* Declared HERE, in the browser-safe descriptor module, for two reasons. It is
|
|
46
|
+
* the `source:` of `listDescriptor`, so the client descriptor and the server
|
|
47
|
+
* route now carry the SAME value instead of the server adding one the client
|
|
48
|
+
* never saw. And the server's `publishReactivity` imports this exact object, so
|
|
49
|
+
* the thing that publishes and the thing that subscribes cannot name two
|
|
50
|
+
* different keys — the failure a pair of strings makes possible and silent.
|
|
51
|
+
*
|
|
52
|
+
* It replaces `_voltro_presence`, a table that was declared and deliberately
|
|
53
|
+
* never written purely to own this name.
|
|
54
|
+
*/
|
|
55
|
+
export declare const presenceRoster: ReactivityChannel;
|
|
56
|
+
|
|
42
57
|
/** Declared for the codegen (`VoltroPlugin.rpcClientDescriptors`) so each tag is
|
|
43
58
|
* emitted into the generated client group. Kept in lockstep with the exports. */
|
|
44
59
|
export declare const presenceRpcClientImports: ReadonlyArray<PluginRpcClientDescriptor>;
|