@voltro/plugin-presence 0.87.0 → 0.88.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/dist/errors.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Schema as e } from "effect";
2
- //#region src/errors.ts
2
+ // src/errors.ts
3
3
  var t = class extends e.TaggedError()("PresenceKeyMissing", { channel: e.String }) {}, n = class extends e.TaggedError()("PresenceStoreUnavailable", {}) {};
4
- //#endregion
5
4
  export { t as PresenceKeyMissing, n as PresenceStoreUnavailable };
package/dist/index.d.ts CHANGED
@@ -35,6 +35,9 @@ export declare interface AttachPresenceBusOptions {
35
35
  */
36
36
  readonly onRosterChanged?: (() => void) | undefined;
37
37
  readonly now?: () => number;
38
+ /** Called when binding settles: acquired, local-only, or failed. Pending
39
+ * acquisition and a closed handle never report a usable transport. */
40
+ readonly onTransportSettled?: (connected: boolean, cause?: unknown) => void;
38
41
  }
39
42
 
40
43
  /**
@@ -51,7 +54,7 @@ export declare const PRESENCE_BROADCAST_CHANNEL = "voltro:presence";
51
54
  export declare interface PresenceBusHandle {
52
55
  readonly tracker: PresenceTracker;
53
56
  /**
54
- * Whether a cross-instance transport is attached.
57
+ * Whether the transport's subscription has been acquired and is still owned.
55
58
  *
56
59
  * Exposed so the plugin can WARN at boot. Without one every replica keeps a
57
60
  * correct roster of its own clients and nothing errors — which is why the
package/dist/index.js CHANGED
@@ -4,63 +4,74 @@ import { PresenceKeyMissing as r, PresenceStoreUnavailable as i } from "./errors
4
4
  import { BASE_NAME as a, heartbeatDescriptor as o, leaveDescriptor as s, listDescriptor as c, presenceRoster as l, presenceRpcClientImports as u } from "./rpc.js";
5
5
  import { Effect as d } from "effect";
6
6
  import { definePlugin as f, pluginInstanceName as p, publishReactivity as m } from "@voltro/protocol";
7
- //#region src/index.ts
7
+ // src/index.ts
8
8
  var h = a, g = (e = {}) => {
9
9
  if (e.timeoutMs !== void 0 && e.timeoutMs < 1e3) throw Error(`presencePlugin: \`timeoutMs\` is ${e.timeoutMs}, which expires every member before it can heartbeat again.
10
10
  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
11
11
  \`heartbeatMs\`, comfortably. The default pair is 30s server / 15s client.
12
12
  There is no "never expire" spelling: omit \`timeoutMs\` for the default.`);
13
- let t, a = e.timeoutMs ?? 3e4, g = Math.max(1e3, Math.floor(e.sweepIntervalMs ?? a / 3)), _ = p({
13
+ let t = e.timeoutMs ?? 3e4, a = Math.max(1e3, Math.floor(e.sweepIntervalMs ?? t / 3)), g = p({
14
14
  base: h,
15
15
  alias: e.alias,
16
16
  instance: e.name
17
- }), v, y, b = !1, x = (e) => {
17
+ }), _, v, y = () => {
18
+ let e = v ?? _;
19
+ _ = void 0, e !== void 0 && (v = e, e.detach(), v = void 0);
20
+ }, b, x = !1, S, C = 0, w = !1, T = !1, E, D, O, k = () => {
21
+ let e = D;
22
+ if (x) {
23
+ if (e === void 0) {
24
+ let e = _ === void 0 ? "awaiting-binding" : T ? "connecting" : void 0;
25
+ e !== void 0 && E !== `${C}:${e}` && (E = `${C}:${e}`, S?.info("presence transport", { state: e }));
26
+ } else e.generation === C && e !== O && (O = e, S?.info("presence transport", { crossInstance: e.connected }), !e.connected && (e.hasProvider ? S?.warn("presence: could not subscribe — this instance will only see its own members", { cause: e.cause }) : S?.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.")));
27
+ }
28
+ }, A = (e) => {
18
29
  let t = e === void 0 || e.includes("presence");
19
- if (y !== void 0 && y !== t) throw Error("Presence bind and activation workloads must agree");
20
- return y = t, t;
21
- }, S, C, w, T = () => {
22
- w === void 0 && v !== void 0 && (w = setInterval(() => {
23
- let e = v;
30
+ if (b !== void 0 && b !== t) throw Error("Presence bind and activation workloads must agree");
31
+ return b = t, t;
32
+ }, j, M, N, P = () => {
33
+ N === void 0 && _ !== void 0 && (N = setInterval(() => {
34
+ let e = _;
24
35
  if (e === void 0) return;
25
- let t = e.tracker.sweep(a);
26
- if (t.length !== 0) {
27
- for (let n of t) e.announce(n);
28
- O();
36
+ let n = e.tracker.sweep(t);
37
+ if (n.length !== 0) {
38
+ for (let t of n) e.announce(t);
39
+ L();
29
40
  }
30
- }, g), w.unref?.());
31
- }, E = (e, t) => typeof t == "string" && t.length > 0 ? t : e.request.subject?.id ?? null, D = (e) => e.request.subject?.tenantId ?? null, O = () => {
32
- m(S, l);
33
- }, k = [
41
+ }, a), N.unref?.());
42
+ }, F = (e, t) => typeof t == "string" && t.length > 0 ? t : e.request.subject?.id ?? null, I = (e) => e.request.subject?.tenantId ?? null, L = () => {
43
+ m(j, l);
44
+ }, R = [
34
45
  {
35
46
  ...o,
36
47
  description: "Register/refresh the caller's presence in a channel.",
37
48
  execute: (t, n) => d.gen(function* () {
38
49
  let a = t;
39
- if (!v) return yield* d.fail(new i({}));
40
- let o = E(n, a.key);
50
+ if (!_) return yield* d.fail(new i({}));
51
+ let o = F(n, a.key);
41
52
  if (o === null) return yield* d.fail(new r({ channel: a.channel }));
42
- let s = e.resolveMember !== void 0 && C !== void 0 ? yield* d.promise(async () => e.resolveMember({
53
+ let s = e.resolveMember !== void 0 && M !== void 0 ? yield* d.promise(async () => e.resolveMember({
43
54
  subject: n.request.subject,
44
55
  channel: a.channel,
45
56
  key: o,
46
- store: C
57
+ store: M
47
58
  })) : void 0, c = e.resolveMember !== void 0 && e.identityFields !== void 0 ? Object.fromEntries(Object.entries(a.meta ?? {}).filter(([t]) => !e.identityFields.includes(t))) : a.meta, l = s === void 0 ? c : {
48
59
  ...c ?? {},
49
60
  ...s
50
- }, u = v.tracker.track({
61
+ }, u = _.tracker.track({
51
62
  channel: a.channel,
52
- tenantId: D(n),
63
+ tenantId: I(n),
53
64
  key: o,
54
65
  ...l === void 0 ? {} : { meta: l }
55
66
  });
56
- return v.announce(u.delta), u.changed && O(), { ok: !0 };
67
+ return _.announce(u.delta), u.changed && L(), { ok: !0 };
57
68
  })
58
69
  },
59
70
  {
60
71
  ...c,
61
72
  description: "The online roster of a channel (heartbeat-fresh members). Push-driven — updates live.",
62
73
  execute: (e, t) => d.gen(function* () {
63
- return v ? v.tracker.roster(e.channel, D(t)).map((e) => ({
74
+ return _ ? _.tracker.roster(e.channel, I(t)).map((e) => ({
64
75
  key: e.key,
65
76
  meta: e.meta
66
77
  })) : yield* d.fail(new i({}));
@@ -71,53 +82,56 @@ var h = a, g = (e = {}) => {
71
82
  description: "Remove the caller's presence from a channel.",
72
83
  execute: (e, t) => d.gen(function* () {
73
84
  let n = e;
74
- if (!v) return yield* d.fail(new i({}));
75
- let a = E(t, n.key);
85
+ if (!_) return yield* d.fail(new i({}));
86
+ let a = F(t, n.key);
76
87
  if (a === null) return yield* d.fail(new r({ channel: n.channel }));
77
- let o = v.tracker.untrack({
88
+ let o = _.tracker.untrack({
78
89
  channel: n.channel,
79
- tenantId: D(t),
90
+ tenantId: I(t),
80
91
  key: a
81
92
  });
82
- return v.announce(o.delta), o.changed && O(), { ok: !0 };
93
+ return _.announce(o.delta), o.changed && L(), { ok: !0 };
83
94
  })
84
95
  }
85
96
  ];
86
97
  return f({
87
- name: _,
98
+ name: g,
88
99
  baseName: h,
89
100
  workloads: ["rpc", "presence"],
90
101
  description: "Ephemeral realtime presence — heartbeat roster per channel.",
91
- routes: k,
102
+ routes: R,
92
103
  rpcClientDescriptors: u,
93
104
  bindDataStore: (e, t) => {
94
- if (!x(t?.workloads)) return;
95
- S = e, C = e;
96
- let r = t;
97
- v = n({
98
- instanceId: r?.instanceId ?? `presence-${process.pid}`,
99
- membership: r?.membership ?? { onChange: () => () => {} },
100
- ...r?.broadcast === void 0 ? {} : { provider: r.broadcast },
101
- ...r?.broadcastChannels?.presence === void 0 ? {} : { channel: r.broadcastChannels.presence },
102
- onRosterChanged: O
103
- }), b && T();
105
+ if (w || !A(t?.workloads)) return;
106
+ let r = ++C;
107
+ D = void 0, O = void 0, E = void 0, y(), j = e, M = e;
108
+ let i = t;
109
+ T = i?.broadcast !== void 0, _ = n({
110
+ instanceId: i?.instanceId ?? `presence-${process.pid}`,
111
+ membership: i?.membership ?? { onChange: () => () => {} },
112
+ ...i?.broadcast === void 0 ? {} : { provider: i.broadcast },
113
+ ...i?.broadcastChannels?.presence === void 0 ? {} : { channel: i.broadcastChannels.presence },
114
+ onRosterChanged: L,
115
+ logger: { warn: (e, t) => S?.warn(e, t) },
116
+ onTransportSettled: (e, t) => {
117
+ r === C && (T = !1, D = {
118
+ generation: r,
119
+ connected: e,
120
+ hasProvider: i?.broadcast !== void 0,
121
+ cause: t
122
+ }, k());
123
+ }
124
+ }), x && (P(), k());
104
125
  },
105
126
  onActivate: (e) => d.sync(() => {
106
- if (!x(e.workloads)) return;
107
- b = !0, T(), e.logger.info("presence active", {
108
- timeoutMs: a,
109
- sweepEveryMs: g
110
- });
111
- let n = Number(process.env.VOLTRO_PRESENCE_BROKER_GRACE_MS ?? 5e3), r = setTimeout(() => {
112
- let t = v?.hasTransport === !0;
113
- 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.");
114
- }, n);
115
- r.unref?.(), t = r;
127
+ A(e.workloads) && (x || (w = !1, S = e.logger, x = !0, P(), e.logger.info("presence active", {
128
+ timeoutMs: t,
129
+ sweepEveryMs: a
130
+ }), k()));
116
131
  }),
117
132
  onDeactivate: () => d.sync(() => {
118
- b = !1, t !== void 0 && (clearTimeout(t), t = void 0), w !== void 0 && (clearInterval(w), w = void 0), v?.detach(), v = void 0, S = void 0, C = void 0;
133
+ x = !1, w = !0, C += 1, T = !1, E = void 0, D = void 0, O = void 0, N !== void 0 && (clearInterval(N), N = void 0), j = void 0, M = void 0, y();
119
134
  })
120
135
  });
121
136
  };
122
- //#endregion
123
137
  export { t as PRESENCE_BROADCAST_CHANNEL, r as PresenceKeyMissing, i as PresenceStoreUnavailable, e as PresenceTracker, n as attachPresenceBus, g as presencePlugin, l as presenceRoster };
package/dist/rpc.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { PresenceKeyMissing as e, PresenceStoreUnavailable as t } from "./errors.js";
2
2
  import { Schema as n } from "effect";
3
3
  import { defineMutation as r, defineQuery as i, reactivityChannel as a } from "@voltro/protocol";
4
- //#region src/rpc.ts
4
+ // src/rpc.ts
5
5
  var o = "@voltro/plugin-presence", s = a("presence"), c = n.Struct({
6
6
  key: n.String,
7
7
  meta: n.NullOr(n.Record(n.String, n.Unknown))
@@ -57,5 +57,4 @@ var o = "@voltro/plugin-presence", s = a("presence"), c = n.Struct({
57
57
  }
58
58
  }
59
59
  ];
60
- //#endregion
61
60
  export { o as BASE_NAME, l as heartbeatDescriptor, d as leaveDescriptor, u as listDescriptor, s as presenceRoster, f as presenceRpcClientImports };
package/dist/tracker.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/tracker.ts
1
+ // src/tracker.ts
2
2
  var e = (e, t) => `${e ?? "~"}::${t}`, t = (e) => e === void 0 ? "undefined" : typeof e != "object" || !e ? JSON.stringify(e) ?? "undefined" : Array.isArray(e) ? `[${e.map(t).join(",")}]` : `{${Object.entries(e).sort(([e], [t]) => e < t ? -1 : +(e > t)).map(([e, n]) => `${JSON.stringify(e)}:${t(n)}`).join(",")}}`, n = (e, n) => e === n || e !== null && n !== null && t(e) === t(n), r = class {
3
3
  partitions = /* @__PURE__ */ new Map();
4
4
  selfId;
@@ -130,5 +130,4 @@ var e = (e, t) => `${e ?? "~"}::${t}`, t = (e) => e === void 0 ? "undefined" : t
130
130
  return this.owners().reduce((e, t) => e + t.members, 0);
131
131
  }
132
132
  };
133
- //#endregion
134
133
  export { r as PresenceTracker };
@@ -1,17 +1,24 @@
1
1
  import { PresenceTracker as e } from "./tracker.js";
2
2
  import { Effect as t } from "effect";
3
- //#region src/trackerBus.ts
3
+ // src/trackerBus.ts
4
4
  var n = "voltro:presence", r = (e) => typeof e == "object" && !!e && typeof e.owner == "string" && typeof e.delta == "object" && e.delta !== null, i = (e) => typeof e == "object" && !!e && typeof e.owner == "string" && Array.isArray(e.sync), a = (n) => {
5
5
  let a = new e({
6
6
  instanceId: n.instanceId,
7
7
  ...n.now === void 0 ? {} : { now: n.now }
8
- }), o = n.logger, s = () => {
8
+ }), o = n.logger, s = !1, c = !1, l = (e, t) => {
9
+ if (!s) try {
10
+ n.onTransportSettled?.(e, t);
11
+ } catch (e) {
12
+ o?.warn("presence: transport observer failed", { cause: e });
13
+ }
14
+ }, u = () => {
9
15
  try {
10
16
  n.onRosterChanged?.();
11
17
  } catch {}
12
- }, c = n.membership.onChange((e) => {
18
+ }, d = n.membership.onChange((e) => {
19
+ if (s) return;
13
20
  if (e.kind === "joined") {
14
- f();
21
+ h();
15
22
  return;
16
23
  }
17
24
  let t = a.dropOwner(e.instanceId);
@@ -19,17 +26,18 @@ var n = "voltro:presence", r = (e) => typeof e == "object" && !!e && typeof e.ow
19
26
  instanceId: e.instanceId,
20
27
  members: t,
21
28
  reason: e.kind
22
- }), s()), e.kind === "restarted" && f();
23
- }), l = n.provider, u = n.channel ?? "voltro:presence", d = (e) => {
24
- l !== void 0 && t.runPromise(l.publish(u, JSON.stringify(e))).catch(() => {});
25
- }, f = () => {
29
+ }), u()), e.kind === "restarted" && h();
30
+ }), f = n.provider, p = n.channel ?? "voltro:presence", m = (e) => {
31
+ s || f === void 0 || t.runPromise(f.publish(p, JSON.stringify(e))).catch(() => {});
32
+ }, h = () => {
26
33
  let e = a.ownSnapshot();
27
- e.length !== 0 && d({
34
+ e.length !== 0 && m({
28
35
  owner: n.instanceId,
29
36
  sync: e
30
37
  });
31
- }, p;
32
- return l !== void 0 && t.runPromise(l.subscribe(u, (e) => {
38
+ }, g;
39
+ return f === void 0 ? queueMicrotask(() => l(!1)) : t.runPromise(f.subscribe(p, (e) => {
40
+ if (s) return;
33
41
  let t;
34
42
  try {
35
43
  t = JSON.parse(e);
@@ -39,25 +47,43 @@ var n = "voltro:presence", r = (e) => typeof e == "object" && !!e && typeof e.ow
39
47
  if (i(t)) {
40
48
  let e = !1;
41
49
  for (let n of t.sync) e = a.apply(t.owner, n) || e;
42
- e && s();
43
- } else r(t) && a.apply(t.owner, t.delta) && s();
50
+ e && u();
51
+ } else r(t) && a.apply(t.owner, t.delta) && u();
44
52
  })).then((e) => {
45
- p = e;
53
+ if (g = e, s) try {
54
+ e(), g = void 0;
55
+ } catch (e) {
56
+ o?.warn("presence: could not release a closed subscription", { cause: e });
57
+ }
58
+ else c = !0, l(!0);
46
59
  }, (e) => {
47
- o?.warn("presence: could not subscribe — this instance will only see its own members", { cause: e });
60
+ s || (n.onTransportSettled === void 0 && o?.warn("presence: could not subscribe — this instance will only see its own members", { cause: e }), l(!1, e));
48
61
  }), {
49
62
  tracker: a,
50
- hasTransport: l !== void 0,
63
+ get hasTransport() {
64
+ return c && !s;
65
+ },
51
66
  announce: (e) => {
52
- d({
67
+ m({
53
68
  owner: n.instanceId,
54
69
  delta: e
55
70
  });
56
71
  },
57
72
  detach: () => {
58
- c(), p?.();
73
+ s = !0, c = !1;
74
+ let e = [];
75
+ try {
76
+ d?.(), d = void 0;
77
+ } catch (t) {
78
+ e.push(t);
79
+ }
80
+ try {
81
+ g?.(), g = void 0;
82
+ } catch (t) {
83
+ e.push(t);
84
+ }
85
+ if (e.length > 0) throw AggregateError(e, "presence: could not detach transport");
59
86
  }
60
87
  };
61
88
  };
62
- //#endregion
63
89
  export { n as PRESENCE_BROADCAST_CHANNEL, a as attachPresenceBus };
package/dist/web.js CHANGED
@@ -2,7 +2,7 @@ import { BASE_NAME as e } from "./rpc.js";
2
2
  import { pluginTag as t } from "@voltro/protocol";
3
3
  import { useCallback as n, useEffect as r, useMemo as i, useRef as a, useState as o } from "react";
4
4
  import { useMutation as s, useSubscription as c } from "@voltro/client";
5
- //#region src/web.ts
5
+ // src/web.ts
6
6
  var l = (n) => t(e, n), u = (e, t = {}) => {
7
7
  let { meta: o, key: u, heartbeatMs: d = 15e3, apiName: f = "app" } = t, p = s(f, l("heartbeat")), m = s(f, l("leave")), h = a(p), g = a(m);
8
8
  h.current = p, g.current = m;
@@ -111,5 +111,4 @@ var l = (n) => t(e, n), u = (e, t = {}) => {
111
111
  }
112
112
  }), [e, n]);
113
113
  };
114
- //#endregion
115
114
  export { d as activeTypers, u as usePresence, p as usePresenceChannel, f as useTyping };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-presence",
3
- "version": "0.87.0",
3
+ "version": "0.88.0",
4
4
  "description": "Ephemeral realtime presence — who is online in a channel, with heartbeat + live roster + per-member metadata (cursor, status — client-supplied and relayed verbatim; identity fields belong in the server-side resolveMember hook). Held in memory, owner-partitioned; cross-instance with @voltro/plugin-broadcast.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -48,12 +48,12 @@
48
48
  "node": ">=24.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@voltro/client": "0.87.0",
52
- "@voltro/protocol": "0.87.0"
51
+ "@voltro/client": "0.88.0",
52
+ "@voltro/protocol": "0.88.0"
53
53
  },
54
54
  "peerDependencies": {
55
- "@voltro/database": "0.87.0",
56
- "effect": "4.0.1",
55
+ "@voltro/database": "0.88.0",
56
+ "effect": "4.1.0",
57
57
  "react": "^19.0.0"
58
58
  },
59
59
  "peerDependenciesMeta": {