@rig-ts/presence 0.1.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.
@@ -0,0 +1,47 @@
1
+ import { a as Person, c as PresenceTarget, d as sameTarget, i as createPresence, l as onTarget, n as PresenceHandle, o as PresenceActivity, r as PresenceSource, s as PresenceRow, t as PresenceArgs, u as personOfRow } from "./presence-B6KIBpNr.mjs";
2
+
3
+ //#region src/session-key.d.ts
4
+ /**
5
+ * This tab's name for itself, for as long as it is open.
6
+ *
7
+ * A module constant, and **not** `sessionStorage`, which is the answer that looks
8
+ * right. Storage would survive a reload — and there is nothing to survive for,
9
+ * because a reload sends its leave on the way out — while a duplicated tab in
10
+ * Chrome inherits a *copy* of `sessionStorage`. Two tabs would hold one key,
11
+ * write to one row, and overwrite each other's target on every beat, so one
12
+ * person would appear to teleport between the two things they were doing.
13
+ *
14
+ * In memory, every tab is a different tab, which is exactly what the server's
15
+ * unique key means by one.
16
+ */
17
+ declare const SESSION_KEY: string;
18
+ //#endregion
19
+ //#region src/clock.d.ts
20
+ /**
21
+ * Whether a row still means somebody is here.
22
+ *
23
+ * # The clock is the interesting part
24
+ *
25
+ * A browser cannot compare `seenAt` against `Date.now()`. Those are two different
26
+ * clocks, and a laptop five minutes fast shows an empty room while a slow one
27
+ * shows people who left. Deriving an offset from a response header would work and
28
+ * needs a seam the generated client does not have.
29
+ *
30
+ * It does not need one. **The freshest `seenAt` in the collection is itself a
31
+ * reading of the server's clock**, taken at most one heartbeat ago by whichever
32
+ * tab beat most recently — so comparing every row against the newest one instead
33
+ * of against the wall clock cancels the skew entirely. No offset, no header, no
34
+ * extra request.
35
+ *
36
+ * It has one blind spot, and it is the harmless one: when you are alone in the
37
+ * scope the only row is your own, so every comparison is against yourself and
38
+ * nobody expires. The answer that matters there is "there is nobody else here",
39
+ * which is what an empty list already says. `fallbackNow` is what your own last
40
+ * heartbeat answered with, for the case where the collection has arrived and you
41
+ * are not in it.
42
+ */
43
+ declare function freshest(people: readonly Person[], fallbackNow?: string): number;
44
+ /** Whether `person` is fresh, measured against a server-clock reading. */
45
+ declare function isFresh(person: Person, now: number, ttlMs: number): boolean;
46
+ //#endregion
47
+ export { type Person, type PresenceActivity, type PresenceArgs, type PresenceHandle, type PresenceRow, type PresenceSource, type PresenceTarget, SESSION_KEY, createPresence, freshest, isFresh, onTarget, personOfRow, sameTarget };
package/dist/index.mjs ADDED
@@ -0,0 +1,349 @@
1
+ //#region src/clock.ts
2
+ /**
3
+ * Whether a row still means somebody is here.
4
+ *
5
+ * # The clock is the interesting part
6
+ *
7
+ * A browser cannot compare `seenAt` against `Date.now()`. Those are two different
8
+ * clocks, and a laptop five minutes fast shows an empty room while a slow one
9
+ * shows people who left. Deriving an offset from a response header would work and
10
+ * needs a seam the generated client does not have.
11
+ *
12
+ * It does not need one. **The freshest `seenAt` in the collection is itself a
13
+ * reading of the server's clock**, taken at most one heartbeat ago by whichever
14
+ * tab beat most recently — so comparing every row against the newest one instead
15
+ * of against the wall clock cancels the skew entirely. No offset, no header, no
16
+ * extra request.
17
+ *
18
+ * It has one blind spot, and it is the harmless one: when you are alone in the
19
+ * scope the only row is your own, so every comparison is against yourself and
20
+ * nobody expires. The answer that matters there is "there is nobody else here",
21
+ * which is what an empty list already says. `fallbackNow` is what your own last
22
+ * heartbeat answered with, for the case where the collection has arrived and you
23
+ * are not in it.
24
+ */
25
+ function freshest(people, fallbackNow) {
26
+ let newest = fallbackNow === void 0 ? 0 : Date.parse(fallbackNow);
27
+ for (const p of people) {
28
+ const at = Date.parse(p.seenAt);
29
+ if (at > newest) newest = at;
30
+ }
31
+ return newest;
32
+ }
33
+ /** Whether `person` is fresh, measured against a server-clock reading. */
34
+ function isFresh(person, now, ttlMs) {
35
+ if (now === 0 || ttlMs === 0) return true;
36
+ return Date.parse(person.seenAt) > now - ttlMs;
37
+ }
38
+ //#endregion
39
+ //#region src/session-key.ts
40
+ /**
41
+ * This tab's name for itself, for as long as it is open.
42
+ *
43
+ * A module constant, and **not** `sessionStorage`, which is the answer that looks
44
+ * right. Storage would survive a reload — and there is nothing to survive for,
45
+ * because a reload sends its leave on the way out — while a duplicated tab in
46
+ * Chrome inherits a *copy* of `sessionStorage`. Two tabs would hold one key,
47
+ * write to one row, and overwrite each other's target on every beat, so one
48
+ * person would appear to teleport between the two things they were doing.
49
+ *
50
+ * In memory, every tab is a different tab, which is exactly what the server's
51
+ * unique key means by one.
52
+ */
53
+ const SESSION_KEY = randomKey();
54
+ function randomKey() {
55
+ const c = globalThis.crypto;
56
+ if (c?.randomUUID !== void 0) return c.randomUUID();
57
+ return `tab-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
58
+ }
59
+ //#endregion
60
+ //#region src/transport.ts
61
+ /**
62
+ * Sends one heartbeat.
63
+ *
64
+ * Built on the runtime rather than on a generated method, for the reason
65
+ * `web/src/auth` is hand-written in every rig front end: these routes are rig's
66
+ * own, they sit outside `api.base_path`, and no generator writes them.
67
+ *
68
+ * **No retry, and that is deliberate.** A heartbeat that failed is not worth
69
+ * repeating — the next one is seconds away — and the generated client names a
70
+ * write it might repeat with an `Idempotency-Key`, which would have the server
71
+ * record a row in `rig_idempotency` for every beat in the building.
72
+ */
73
+ async function beat(runtime, path, body) {
74
+ const { res, authorization } = await send(runtime, path, "PUT", JSON.stringify(body));
75
+ if (!res.ok) throw new Error(`presence: heartbeat answered ${res.status}`);
76
+ return {
77
+ answer: await res.json(),
78
+ authorization
79
+ };
80
+ }
81
+ /**
82
+ * Says this tab has gone.
83
+ *
84
+ * `keepalive` is the whole reason this is not a generated call: it is a request
85
+ * the browser is allowed to finish after the page is gone, and no option on a
86
+ * generated method asks for it.
87
+ *
88
+ * `sendBeacon` is not the alternative it looks like. It is POST-only, which would
89
+ * be survivable, and it cannot set an `Authorization` header, which is not — so it
90
+ * cannot authenticate at all.
91
+ */
92
+ function leave(runtime, path, sessionKey, authorization) {
93
+ const headers = runtime.baseHeaders();
94
+ headers.set("Content-Type", "application/json");
95
+ if (authorization !== "") headers.set("Authorization", authorization);
96
+ runtime.fetch(runtime.url(path, void 0, void 0, true), {
97
+ method: "DELETE",
98
+ headers,
99
+ body: JSON.stringify({ sessionKey }),
100
+ keepalive: true
101
+ }).catch(() => {});
102
+ }
103
+ /**
104
+ * Applies the credential and makes the call.
105
+ *
106
+ * The `Authorization` the credential produced is read back off the headers this
107
+ * built and returned with the response, so that {@link Beat.authorization} costs
108
+ * nothing: `apply` is called once per request, and asking a second time is not
109
+ * a second read of the same value — with a session credential it is a second
110
+ * run of the whole stale-check-and-exchange path.
111
+ */
112
+ async function send(runtime, path, method, body) {
113
+ const headers = runtime.baseHeaders();
114
+ if (body !== void 0) headers.set("Content-Type", "application/json");
115
+ await runtime.getCredential()?.apply(headers);
116
+ const init = {
117
+ method,
118
+ headers
119
+ };
120
+ if (body !== void 0) init.body = body;
121
+ return {
122
+ res: await runtime.fetch(runtime.url(path, void 0, void 0, true), init),
123
+ authorization: headers.get("Authorization") ?? ""
124
+ };
125
+ }
126
+ /** Turns a target into the three optional fields the route takes. */
127
+ function bodyOf(sessionKey, scope, target, activity) {
128
+ return {
129
+ sessionKey,
130
+ scope,
131
+ targetTable: target.table,
132
+ targetId: target.id,
133
+ targetField: target.field,
134
+ activity
135
+ };
136
+ }
137
+ //#endregion
138
+ //#region src/types.ts
139
+ /** Reads a streamed row as a [Person]. */
140
+ function personOfRow(row) {
141
+ return {
142
+ id: row.id,
143
+ accountId: row.account_id,
144
+ sessionKey: row.session_key,
145
+ scope: row.scope,
146
+ target: {
147
+ table: row.target_table ?? void 0,
148
+ id: row.target_id ?? void 0,
149
+ field: row.target_field ?? void 0
150
+ },
151
+ activity: row.activity === "editing" ? "editing" : "viewing",
152
+ createdAt: row.created_at,
153
+ seenAt: row.seen_at
154
+ };
155
+ }
156
+ /** Whether a target is one a person is on, treating an absent field as a wildcard. */
157
+ function onTarget(person, target) {
158
+ if (target.table !== void 0 && person.target.table !== target.table) return false;
159
+ if (target.id !== void 0 && person.target.id !== target.id) return false;
160
+ if (target.field !== void 0 && person.target.field !== target.field) return false;
161
+ return true;
162
+ }
163
+ /** Whether two targets say the same thing, so a repeated focus writes nothing. */
164
+ function sameTarget(left, right) {
165
+ return left.table === right.table && left.id === right.id && left.field === right.field;
166
+ }
167
+ //#endregion
168
+ //#region src/presence.ts
169
+ /** Reads a teardown out of whatever a subscribe call answered with. */
170
+ function unsubscribeOf(handle) {
171
+ if (typeof handle === "function") return handle;
172
+ if (typeof handle === "object" && handle !== null && typeof handle.unsubscribe === "function") return () => handle.unsubscribe();
173
+ }
174
+ const DEFAULT_PATH = "/presence";
175
+ const DEFAULT_TICK_MS = 1e3;
176
+ const DEFAULT_THROTTLE_MS = 500;
177
+ /**
178
+ * A target as one string, so it can key the answers above.
179
+ *
180
+ * The separator is a character nothing in a table name, a uuid or a column name
181
+ * can hold, so two different targets cannot spell one key. "No target" — which
182
+ * means everybody in the scope — gets a key of its own rather than the three
183
+ * empty strings a target of `{}` would spell.
184
+ */
185
+ function keyOf(target) {
186
+ if (target === void 0) return "*";
187
+ return [
188
+ target.table ?? "",
189
+ target.id ?? "",
190
+ target.field ?? ""
191
+ ].join("\0");
192
+ }
193
+ /**
194
+ * Whether two answers say the same thing, and so whether the previous array can
195
+ * be handed back.
196
+ *
197
+ * Not identity — `others` maps a fresh person out of every row on every call, so
198
+ * the objects always differ — and not a deep comparison either. What it compares
199
+ * is what a caller draws: who is here, in what order, where, and doing what.
200
+ *
201
+ * **`seenAt` is deliberately not among them.** It moves on every heartbeat and
202
+ * nothing renders it, so counting it would redraw every avatar in the room three
203
+ * times a minute for no visible change. A person whose only change is a fresh
204
+ * heartbeat is the same person.
205
+ */
206
+ function sameAnswer(prev, next) {
207
+ if (prev.length !== next.length) return false;
208
+ for (let i = 0; i < next.length; i++) {
209
+ const a = prev[i];
210
+ const b = next[i];
211
+ if (a === void 0 || b === void 0 || a.id !== b.id || a.activity !== b.activity || a.target.table !== b.target.table || a.target.id !== b.target.id || a.target.field !== b.target.field) return false;
212
+ }
213
+ return true;
214
+ }
215
+ /**
216
+ * Starts the presence loop for one tab.
217
+ *
218
+ * It owns six things, and each is a bug if the application owns it instead: the
219
+ * heartbeat schedule, the write throttle, the clock, the tick that makes expiry
220
+ * happen, the visibility rule, and the leave on teardown.
221
+ *
222
+ * **Nothing here is configured with a heartbeat interval.** The server answers
223
+ * one on every beat and the loop re-beats at half of what is left, so changing
224
+ * `presence.ttl` is a deploy of the server rather than a release of the front
225
+ * end — and there is no copy of the number here to disagree with it.
226
+ */
227
+ function createPresence(args) {
228
+ const { runtime, scope, stream, path = DEFAULT_PATH, sessionKey = SESSION_KEY, tickMs = DEFAULT_TICK_MS, throttleMs = DEFAULT_THROTTLE_MS } = args;
229
+ let target = {};
230
+ let activity = "viewing";
231
+ let sent;
232
+ let ttlMs = 0;
233
+ let heartbeatMs = 0;
234
+ let lastSeenAt;
235
+ let authorization = "";
236
+ let beatTimer;
237
+ let throttleTimer;
238
+ let tickTimer;
239
+ let closed = false;
240
+ const listeners = /* @__PURE__ */ new Set();
241
+ const notify = () => {
242
+ for (const fn of listeners) fn();
243
+ };
244
+ const answers = /* @__PURE__ */ new Map();
245
+ const rows = () => typeof stream.toArray === "function" ? stream.toArray() : stream.toArray;
246
+ async function write() {
247
+ if (closed) return;
248
+ const now = {
249
+ target,
250
+ activity
251
+ };
252
+ try {
253
+ const beaten = await beat(runtime, path, bodyOf(sessionKey, scope, now.target, now.activity));
254
+ if (closed) return;
255
+ sent = now;
256
+ lastSeenAt = beaten.answer.seenAt;
257
+ ttlMs = beaten.answer.ttlSeconds * 1e3;
258
+ heartbeatMs = beaten.answer.heartbeatSeconds * 1e3;
259
+ authorization = beaten.authorization;
260
+ notify();
261
+ } catch {}
262
+ schedule();
263
+ }
264
+ function schedule() {
265
+ if (closed || hidden()) return;
266
+ clearTimeout(beatTimer);
267
+ beatTimer = setTimeout(() => void write(), heartbeatMs === 0 ? 5e3 : Math.max(1e3, heartbeatMs));
268
+ }
269
+ function focus(next, act = "viewing") {
270
+ const wanted = next ?? {};
271
+ if (sent !== void 0 && sameTarget(sent.target, wanted) && sent.activity === act) return;
272
+ target = wanted;
273
+ activity = act;
274
+ if (throttleTimer !== void 0) return;
275
+ write();
276
+ throttleTimer = setTimeout(() => {
277
+ throttleTimer = void 0;
278
+ if (sent === void 0 || !sameTarget(sent.target, target) || sent.activity !== activity) write();
279
+ }, throttleMs);
280
+ }
281
+ function others(only) {
282
+ const people = rows().map(personOfRow);
283
+ const now = freshest(people, lastSeenAt);
284
+ const next = [];
285
+ for (const p of people) {
286
+ if (p.sessionKey === sessionKey) continue;
287
+ if (!isFresh(p, now, ttlMs)) continue;
288
+ if (only !== void 0 && !onTarget(p, only)) continue;
289
+ next.push(p);
290
+ }
291
+ next.sort((a, b) => b.seenAt.localeCompare(a.seenAt));
292
+ if (answers.size > 256) answers.clear();
293
+ const key = keyOf(only);
294
+ const prev = answers.get(key);
295
+ if (prev !== void 0 && sameAnswer(prev, next)) return prev;
296
+ answers.set(key, next);
297
+ return next;
298
+ }
299
+ function hidden() {
300
+ return globalThis.document?.visibilityState === "hidden";
301
+ }
302
+ function onVisibility() {
303
+ if (hidden()) {
304
+ stop();
305
+ sendLeave();
306
+ return;
307
+ }
308
+ sent = void 0;
309
+ write();
310
+ }
311
+ function sendLeave() {
312
+ leave(runtime, path, sessionKey, authorization);
313
+ }
314
+ function stop() {
315
+ clearTimeout(beatTimer);
316
+ clearTimeout(throttleTimer);
317
+ beatTimer = void 0;
318
+ throttleTimer = void 0;
319
+ }
320
+ const doc = globalThis.document;
321
+ const win = globalThis.window;
322
+ doc?.addEventListener("visibilitychange", onVisibility);
323
+ win?.addEventListener("pagehide", sendLeave);
324
+ const untap = stream.subscribeChanges === void 0 ? void 0 : unsubscribeOf(stream.subscribeChanges(notify));
325
+ tickTimer = setInterval(notify, tickMs);
326
+ if (!hidden()) write();
327
+ return {
328
+ focus,
329
+ others,
330
+ subscribe(fn) {
331
+ listeners.add(fn);
332
+ return () => listeners.delete(fn);
333
+ },
334
+ leave: sendLeave,
335
+ close() {
336
+ if (closed) return;
337
+ closed = true;
338
+ stop();
339
+ clearInterval(tickTimer);
340
+ untap?.();
341
+ doc?.removeEventListener("visibilitychange", onVisibility);
342
+ win?.removeEventListener("pagehide", sendLeave);
343
+ listeners.clear();
344
+ sendLeave();
345
+ }
346
+ };
347
+ }
348
+ //#endregion
349
+ export { SESSION_KEY, createPresence, freshest, isFresh, onTarget, personOfRow, sameTarget };
@@ -0,0 +1,154 @@
1
+ import { Runtime } from "@rig-ts/client";
2
+
3
+ //#region src/types.d.ts
4
+ /** What a browser tab says it is looking at. */
5
+ type PresenceTarget = {
6
+ /** The table the row is in. Absent is the scope itself rather than a row in it. */table?: string | undefined; /** Which row. Absent with a table present is a list of them. */
7
+ id?: string | undefined; /** Which control has focus. Absent is looking rather than typing. */
8
+ field?: string | undefined;
9
+ };
10
+ /** Whether somebody is looking or typing. */
11
+ type PresenceActivity = "viewing" | "editing";
12
+ /**
13
+ * One presence, as this package reports it.
14
+ *
15
+ * camelCase, and normalising to it is most of what this package is for. The same
16
+ * row arrives two ways in a rig application and they do not agree about keys: the
17
+ * live shape carries Postgres column names, because nothing between the database
18
+ * and the browser rewrites them, while `GET /presence` answers what the
19
+ * hand-written route's Go struct declares. A caller should not have to know which
20
+ * door a row came through.
21
+ */
22
+ type Person = {
23
+ id: string;
24
+ accountId: string; /** Which of that account's tabs this is. Two tabs are two people here. */
25
+ sessionKey: string;
26
+ scope: string;
27
+ target: PresenceTarget;
28
+ activity: PresenceActivity; /** When this tab first appeared. It does not move on a heartbeat. */
29
+ createdAt: string; /** The last heartbeat. Whether this row means somebody is here is a comparison against it. */
30
+ seenAt: string;
31
+ };
32
+ /**
33
+ * The shape of a streamed row, which is what the generated collection holds.
34
+ *
35
+ * Declared here rather than imported from a project's generated types so this
36
+ * package compiles on its own. A generated `RigPresenceRow` is assignable to it:
37
+ * the sync service sends every column on every row, with a null where the column
38
+ * is null, which is why the nullable members are nullable rather than optional.
39
+ */
40
+ type PresenceRow = {
41
+ id: string;
42
+ tenant_id: string;
43
+ account_id: string;
44
+ session_key: string;
45
+ scope: string;
46
+ target_table: string | null;
47
+ target_id: string | null;
48
+ target_field: string | null;
49
+ activity: string;
50
+ created_at: string;
51
+ seen_at: string;
52
+ };
53
+ /** Reads a streamed row as a [Person]. */
54
+ declare function personOfRow(row: PresenceRow): Person;
55
+ /** Whether a target is one a person is on, treating an absent field as a wildcard. */
56
+ declare function onTarget(person: Person, target: PresenceTarget): boolean;
57
+ /** Whether two targets say the same thing, so a repeated focus writes nothing. */
58
+ declare function sameTarget(left: PresenceTarget, right: PresenceTarget): boolean;
59
+ //#endregion
60
+ //#region src/presence.d.ts
61
+ /**
62
+ * The minimum a collection has to look like for this package to read it.
63
+ *
64
+ * Structural rather than the collection type itself, which is what keeps this
65
+ * package from depending on `@tanstack/db` — and a project's sync stack has to
66
+ * exist exactly once, so a second copy pulled in here would be a collection
67
+ * nothing could read.
68
+ *
69
+ * `subscribeChanges` returns whatever the collection returns. TanStack DB hands
70
+ * back a subscription object with an `unsubscribe` method rather than the
71
+ * teardown function the name suggests, so both shapes are accepted and
72
+ * {@link unsubscribeOf} sorts them out.
73
+ */
74
+ type PresenceSource = {
75
+ toArray: PresenceRow[] | (() => PresenceRow[]);
76
+ subscribeChanges?: (fn: () => void) => unknown;
77
+ };
78
+ /** What {@link createPresence} needs. */
79
+ type PresenceArgs = {
80
+ /**
81
+ * The client the heartbeat authenticates through, and whose origin it
82
+ * resolves against. Taking the whole runtime rather than a base URL is what
83
+ * lets a `Session` refresh before a beat inherits a token about to expire.
84
+ */
85
+ runtime: Runtime;
86
+ /**
87
+ * Which part of the application this is — a board, a document. It has to
88
+ * match the `scope` the collection was created with, or this tab writes into
89
+ * a scope it is not subscribed to and never sees itself.
90
+ */
91
+ scope: string; /** The generated `rig_presence` collection. */
92
+ stream: PresenceSource; /** Where presencehttp is mounted. `/presence` unless the project moved it. */
93
+ path?: string; /** This tab's identity. Defaults to {@link SESSION_KEY}. */
94
+ sessionKey?: string;
95
+ /**
96
+ * How often `others()` is recomputed so rows can age out, in milliseconds.
97
+ *
98
+ * A second, because absence is the passage of time rather than an event and
99
+ * no collection has a change to fire for it. This is the whole reason a live
100
+ * query over the collection is not enough on its own.
101
+ */
102
+ tickMs?: number;
103
+ /**
104
+ * The shortest gap between two writes, in milliseconds.
105
+ *
106
+ * Leading edge immediately and a trailing edge at this, so tabbing through
107
+ * five controls in a second is two writes rather than five and the person
108
+ * watching still sees the first one at once. Below about 300ms the write rate
109
+ * approaches the render rate, and every write is fanned out to every
110
+ * subscriber in the tenant.
111
+ */
112
+ throttleMs?: number;
113
+ };
114
+ /** A running presence loop. */
115
+ type PresenceHandle = {
116
+ /**
117
+ * Say what this tab is looking at. Safe to call from a focus handler on every
118
+ * event: repeated calls with the same target write nothing.
119
+ */
120
+ focus(target: PresenceTarget | null, activity?: PresenceActivity): void;
121
+ /**
122
+ * Everybody else who is here, freshest first, expired rows already dropped.
123
+ *
124
+ * The same array comes back until the answer actually changes, which is the
125
+ * contract `useSyncExternalStore` and its equivalents require of a snapshot:
126
+ * they compare by identity, so a fresh array every call is an unbounded
127
+ * re-render rather than a wasted one.
128
+ */
129
+ others(target?: PresenceTarget): Person[];
130
+ /**
131
+ * Subscribe to changes in what `others()` answers.
132
+ *
133
+ * Fires on a collection change **and** on the tick, which is the contract a
134
+ * framework binding needs: a person becoming absent has no event.
135
+ */
136
+ subscribe(fn: () => void): () => void; /** Leave now rather than waiting out the TTL. */
137
+ leave(): void; /** Stop the loop and the listeners. */
138
+ close(): void;
139
+ };
140
+ /**
141
+ * Starts the presence loop for one tab.
142
+ *
143
+ * It owns six things, and each is a bug if the application owns it instead: the
144
+ * heartbeat schedule, the write throttle, the clock, the tick that makes expiry
145
+ * happen, the visibility rule, and the leave on teardown.
146
+ *
147
+ * **Nothing here is configured with a heartbeat interval.** The server answers
148
+ * one on every beat and the loop re-beats at half of what is left, so changing
149
+ * `presence.ttl` is a deploy of the server rather than a release of the front
150
+ * end — and there is no copy of the number here to disagree with it.
151
+ */
152
+ declare function createPresence(args: PresenceArgs): PresenceHandle;
153
+ //#endregion
154
+ export { Person as a, PresenceTarget as c, sameTarget as d, createPresence as i, onTarget as l, PresenceHandle as n, PresenceActivity as o, PresenceSource as r, PresenceRow as s, PresenceArgs as t, personOfRow as u };
@@ -0,0 +1,15 @@
1
+ import { a as Person, c as PresenceTarget, n as PresenceHandle } from "./presence-B6KIBpNr.mjs";
2
+
3
+ //#region src/react.d.ts
4
+ /**
5
+ * Everybody else who is here, as a React hook.
6
+ *
7
+ * Three lines, and a second entry point rather than part of the core, so a
8
+ * project that does not use React never has `react` reachable from the module it
9
+ * imports. The core exposes `subscribe` and `others` in exactly the shape
10
+ * `useSyncExternalStore` wants, which is what keeps the binding this small — and
11
+ * what makes a binding for another framework the same size.
12
+ */
13
+ declare function usePresence(handle: PresenceHandle, target?: PresenceTarget): Person[];
14
+ //#endregion
15
+ export { usePresence };
package/dist/react.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import { useSyncExternalStore } from "react";
2
+ //#region src/react.ts
3
+ /**
4
+ * Everybody else who is here, as a React hook.
5
+ *
6
+ * Three lines, and a second entry point rather than part of the core, so a
7
+ * project that does not use React never has `react` reachable from the module it
8
+ * imports. The core exposes `subscribe` and `others` in exactly the shape
9
+ * `useSyncExternalStore` wants, which is what keeps the binding this small — and
10
+ * what makes a binding for another framework the same size.
11
+ */
12
+ function usePresence(handle, target) {
13
+ return useSyncExternalStore(handle.subscribe, () => handle.others(target), () => EMPTY);
14
+ }
15
+ const EMPTY = [];
16
+ //#endregion
17
+ export { usePresence };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@rig-ts/presence",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "description": "Who is here, and what they are looking at, in a rig application",
7
+ "license": "MIT",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.mts",
14
+ "default": "./dist/index.mjs"
15
+ },
16
+ "./react": {
17
+ "types": "./dist/react.d.mts",
18
+ "default": "./dist/react.mjs"
19
+ }
20
+ },
21
+ "peerDependencies": {
22
+ "react": ">=18",
23
+ "@rig-ts/client": "^0.1.0",
24
+ "@rig-ts/electric": "^0.1.0"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "react": {
28
+ "optional": true
29
+ }
30
+ },
31
+ "devDependencies": {
32
+ "@types/react": "19.2.2",
33
+ "react": "19.2.0",
34
+ "tsdown": "0.21.10",
35
+ "typescript": "5.9.3",
36
+ "@rig-ts/client": "0.1.0",
37
+ "@rig-ts/electric": "0.1.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/simonjanss/rig.git",
45
+ "directory": "ts/packages/presence"
46
+ },
47
+ "scripts": {
48
+ "build": "tsdown",
49
+ "typecheck": "tsc --noEmit"
50
+ }
51
+ }