@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.
- package/dist/index.d.mts +47 -0
- package/dist/index.mjs +349 -0
- package/dist/presence-B6KIBpNr.d.mts +154 -0
- package/dist/react.d.mts +15 -0
- package/dist/react.mjs +17 -0
- package/package.json +51 -0
package/dist/index.d.mts
ADDED
|
@@ -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 };
|
package/dist/react.d.mts
ADDED
|
@@ -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
|
+
}
|