@estiva-app/platform 0.2.0 → 0.3.1

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 CHANGED
@@ -1,5 +1,40 @@
1
1
  # @estiva-app/platform
2
2
 
3
+ ## 0.3.1
4
+
5
+ Subscribers on one interval share one timer (PER-6). No API change.
6
+
7
+ 0.3.0 started a `setInterval` per subscriber, at the moment it subscribed, so
8
+ three views on one page on the same 10 s woke at three unrelated phases — Ship's
9
+ project page read every ~3 s, and never inside the 1 s merge. Now there is one
10
+ timer per distinct `intervalMs`, and every subscriber on it wakes in the same
11
+ tick.
12
+
13
+ - A subscriber that joined less than half an interval ago sits out a tick: its
14
+ caller has just done its own first read. Its first scheduled refresh comes
15
+ between 0.5× and 1.5× the interval after subscribing, then on the shared beat.
16
+ - Different intervals still have separate timers, and separate phases.
17
+
18
+ ## 0.3.0
19
+
20
+ When a tab re-reads, and when the relay has asked it to wait (PER-13). Additive.
21
+
22
+ Ship's `useRefresh` + `relayBudget` taken out of the app, so Peek can stop
23
+ firing every read twice per tab switch and stop retrying refused reads in a
24
+ herd. On 2026-09-23 one person's key reached 306 requests in a minute across
25
+ two Peek tabs and a Ship tab, against buzz's 300-per-pubkey window.
26
+
27
+ - `createRefreshScheduler({ document, window, budget })` — one `focus` and one
28
+ `visibilitychange` listener per tab, fanned out to every subscriber. Two
29
+ triggers inside `mergeMs` (default 1 s) are one read, per subscriber. The
30
+ interval never runs in a hidden tab. Nothing runs while the budget is paused,
31
+ and nothing is queued for when it ends.
32
+ - `createRelayBudget({ channel? })` — the pause the relay's `retry in N s`
33
+ names. Extends, never shortens. `whenClear()` resolves once it has passed.
34
+ Given a `BroadcastChannel`, a pause one tab learns holds in the app's others.
35
+ - `isRateLimited`, `retryHintMs`, `DEFAULT_BACKOFF_MS`, `MIN_BACKOFF_MS`,
36
+ `DEFAULT_MERGE_MS`.
37
+
3
38
  ## 0.2.0
4
39
 
5
40
  A Folder notification may carry the event that caused it (PER-8). Additive:
package/README.md CHANGED
@@ -109,6 +109,37 @@ Pass `kinds` while you are at it: a kindless wide filter was refused outright on
109
109
  that other build with `restricted: p-gated events require #p matching your
110
110
  pubkey`.
111
111
 
112
+ ### Refreshing, and waiting when the relay says so
113
+
114
+ Buzz meters `POST /query` at 300 a minute **per pubkey**, so every tab and app
115
+ signed in as one person shares one allowance. Build one budget and one
116
+ scheduler per tab:
117
+
118
+ ```ts
119
+ import { createRefreshScheduler, createRelayBudget } from '@estiva-app/platform'
120
+
121
+ // The transport writes it; the cadence reads it.
122
+ export const relayBudget = createRelayBudget({
123
+ // Optional: the app's other tabs learn a pause from this one.
124
+ channel: new BroadcastChannel('my-app-relay-budget'),
125
+ })
126
+ export const refreshScheduler = createRefreshScheduler({ document, window, budget: relayBudget })
127
+
128
+ // In the transport, on every answer:
129
+ if (status === 429 || isRateLimited(body)) relayBudget.noteRateLimited(body)
130
+
131
+ // In a hook: focus, becoming visible, and every 30 s while visible. A tab
132
+ // switch raises two events and costs one read; nothing runs while paused.
133
+ useEffect(() => refreshScheduler.subscribe(reload, { interval: !live }), [live])
134
+
135
+ // Before retrying a refused read: every waiter goes after the longest pause
136
+ // anybody learned, not its own shorter one.
137
+ await relayBudget.whenClear()
138
+ ```
139
+
140
+ Nothing here gates a read. A read somebody asked for by clicking still goes out
141
+ at once; only the cadence backs off.
142
+
112
143
  ## What this deliberately does not know
113
144
 
114
145
  Where anything is stored. Peek's version took a `LiveProjectionApi`, and
package/dist/index.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * and the two relay clients, and deliberately contains no fold. This package
6
6
  * sits above it and holds the things that are about *running in a tab* — the
7
7
  * one socket a tab should have, the credential read per connect, the network
8
- * coming back, and (SHA-2) the PWA's manifest, service worker and update flow.
8
+ * coming back, when a tab re-reads and when the relay has asked it to wait
9
+ * (PER-13), and (SHA-2) the PWA's manifest, service worker and update flow.
9
10
  *
10
11
  * ## The rule this package is here to keep
11
12
  *
@@ -21,4 +22,5 @@
21
22
  export { MAX_FOLDERS_PER_SUBSCRIPTION, createLiveClient, createLiveClientHolder, type FolderWatch, type LiveClient, type LiveClientHolder, type LiveClientObservers, type LiveClientOptions, } from './live-client.js';
22
23
  export { createFolderActivity, type FolderActivity, type FolderListener, } from './folder-activity.js';
23
24
  export { assumeOnline, browserOnlineSource, type OnlineSource, type OnlineTarget } from './online.js';
25
+ export { DEFAULT_BACKOFF_MS, DEFAULT_MERGE_MS, MIN_BACKOFF_MS, createRefreshScheduler, createRelayBudget, isRateLimited, retryHintMs, type BudgetChannel, type FocusTarget, type RefreshScheduler, type RefreshSchedulerOptions, type RefreshSubscription, type RelayBudget, type RelayBudgetOptions, type SchedulerClock, type VisibilityTarget, } from './refresh.js';
24
26
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AACrG,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,cAAc,CAAA"}
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * and the two relay clients, and deliberately contains no fold. This package
6
6
  * sits above it and holds the things that are about *running in a tab* — the
7
7
  * one socket a tab should have, the credential read per connect, the network
8
- * coming back, and (SHA-2) the PWA's manifest, service worker and update flow.
8
+ * coming back, when a tab re-reads and when the relay has asked it to wait
9
+ * (PER-13), and (SHA-2) the PWA's manifest, service worker and update flow.
9
10
  *
10
11
  * ## The rule this package is here to keep
11
12
  *
@@ -21,4 +22,5 @@
21
22
  export { MAX_FOLDERS_PER_SUBSCRIPTION, createLiveClient, createLiveClientHolder, } from './live-client.js';
22
23
  export { createFolderActivity, } from './folder-activity.js';
23
24
  export { assumeOnline, browserOnlineSource } from './online.js';
25
+ export { DEFAULT_BACKOFF_MS, DEFAULT_MERGE_MS, MIN_BACKOFF_MS, createRefreshScheduler, createRelayBudget, isRateLimited, retryHintMs, } from './refresh.js';
24
26
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,GAMvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,GAGrB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAwC,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,GAMvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,GAGrB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAwC,MAAM,aAAa,CAAA;AACrG,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,iBAAiB,EACjB,aAAa,EACb,WAAW,GAUZ,MAAM,cAAc,CAAA"}
@@ -0,0 +1,163 @@
1
+ /**
2
+ * When a tab re-reads, and when it must not (PER-13).
3
+ *
4
+ * Buzz meters `POST /query` at **300 a minute per pubkey** — a fixed 60-second
5
+ * window, `human_api_calls_per_min`, applied once per HTTP call before the
6
+ * filters are parsed (`enforce_http_admission`, `api/bridge.rs`). Not per app
7
+ * and not per tab: two Peek tabs and a Ship tab signed in as one person share
8
+ * one allowance, and the relay is the only participant that can see all three.
9
+ *
10
+ * Ship and Peek each grew half of the answer. Ship's `useRefresh` merged the two
11
+ * events a tab switch raises and held off after a 429; Peek's
12
+ * `useRefreshTriggers` did neither, so a tab switch fired every read twice, and
13
+ * each refused read waited out its own hint and then retried at the same
14
+ * instant as every other one. Measured on production on 2026-09-23: Miky's key
15
+ * at 306 requests in one minute, single seconds of 40–58, and 33 refusals in
16
+ * one second when a whole fan-out was sent into a pause already in force.
17
+ *
18
+ * ## Two objects, both handed to the app
19
+ *
20
+ * - {@link createRelayBudget} — how long the relay has asked this tab to wait.
21
+ * **The transport writes it** (it sees every 429) and everything that decides
22
+ * whether to read consults it. No reader has to know about quotas.
23
+ * - {@link createRefreshScheduler} — one `visibilitychange` and one `focus`
24
+ * listener for the whole tab, fanning out to every subscriber, each of which
25
+ * still gets Ship's merge: two triggers inside `mergeMs` are one read.
26
+ *
27
+ * Neither is a module-level singleton (ADR 0002 §10 constraint 2): the app
28
+ * builds one of each and keeps the binding, as it does the live-client holder.
29
+ *
30
+ * ## A person's own action is not paused
31
+ *
32
+ * Nothing here gates a read. The budget is advice the *cadence* honours —
33
+ * scheduled refreshes skip while it is in force, and a retry waits it out — so
34
+ * a read somebody asked for by clicking still goes out at once. Backing off a
35
+ * poll costs freshness nobody asked for; backing off a click costs the thing
36
+ * they just did.
37
+ */
38
+ /** The relay's quota refusal, as opposed to any other failure. */
39
+ export declare function isRateLimited(text: string): boolean;
40
+ /**
41
+ * The relay's own wait, in ms, or `undefined` if it did not state one.
42
+ *
43
+ * Buzz writes `rate-limited: quota exceeded; retry in 8s` (`api/bridge.rs`,
44
+ * `connection.rs`). Parsed rather than assumed because the number is what is
45
+ * left of the relay's *window*, not a fixed penalty — it shrinks as the minute
46
+ * rolls on, so a flat wait either spends early or idles long.
47
+ */
48
+ export declare function retryHintMs(reason: string): number | undefined;
49
+ /** How long to wait when the relay refuses without saying for how long. */
50
+ export declare const DEFAULT_BACKOFF_MS = 30000;
51
+ /**
52
+ * The floor under a stated wait. `retry in 0s` means the window has just rolled
53
+ * over; the read that would go out now is the one that was refused, so it still
54
+ * waits a moment rather than none.
55
+ */
56
+ export declare const MIN_BACKOFF_MS = 1000;
57
+ /** What the budget needs of a `BroadcastChannel`. Declared here, see `online.ts`. */
58
+ export interface BudgetChannel {
59
+ postMessage(message: unknown): void;
60
+ addEventListener(type: 'message', listener: (event: {
61
+ data: unknown;
62
+ }) => void): void;
63
+ removeEventListener(type: 'message', listener: (event: {
64
+ data: unknown;
65
+ }) => void): void;
66
+ }
67
+ export interface RelayBudgetOptions {
68
+ /** Default {@link DEFAULT_BACKOFF_MS}. */
69
+ defaultBackoffMs?: number;
70
+ /**
71
+ * Share pauses with the app's other tabs.
72
+ *
73
+ * Tabs of one origin signed in as one person spend one allowance, so a pause
74
+ * one of them learns holds for all of them. Pass a `BroadcastChannel` — the
75
+ * app names it — or leave it out. A tab of another app on another origin
76
+ * cannot be reached this way and learns its own pause from its own refusal.
77
+ */
78
+ channel?: BudgetChannel;
79
+ now?: () => number;
80
+ sleep?: (ms: number) => Promise<void>;
81
+ }
82
+ export interface RelayBudget {
83
+ /**
84
+ * Record a refusal from the relay's own words. Returns the pause in force
85
+ * afterwards, in ms.
86
+ *
87
+ * Extends a pause but never shortens one: two reads refused in the same
88
+ * second carry different remaining windows, and the second answer must not
89
+ * cancel the first.
90
+ */
91
+ noteRateLimited(reason: string): number;
92
+ /** Milliseconds left to wait, or 0. */
93
+ pausedForMs(): number;
94
+ /**
95
+ * Resolves once no pause is in force — immediately if none is.
96
+ *
97
+ * Loops rather than sleeping once: a pause can be extended while somebody is
98
+ * waiting on it, by this tab or another.
99
+ */
100
+ whenClear(): Promise<void>;
101
+ /** Test seam. Nothing in an app clears a pause early. */
102
+ reset(): void;
103
+ /** Detach from the channel, if one was given. */
104
+ close(): void;
105
+ }
106
+ export declare function createRelayBudget(options?: RelayBudgetOptions): RelayBudget;
107
+ /** What the scheduler needs of `document`. */
108
+ export interface VisibilityTarget {
109
+ readonly visibilityState: string;
110
+ addEventListener(type: 'visibilitychange', listener: () => void): void;
111
+ removeEventListener(type: 'visibilitychange', listener: () => void): void;
112
+ }
113
+ /** What the scheduler needs of `window`. */
114
+ export interface FocusTarget {
115
+ addEventListener(type: 'focus', listener: () => void): void;
116
+ removeEventListener(type: 'focus', listener: () => void): void;
117
+ }
118
+ /** Timers, injected so a test can drive them. Handles are opaque. */
119
+ export interface SchedulerClock {
120
+ now(): number;
121
+ setInterval(run: () => void, ms: number): unknown;
122
+ clearInterval(handle: unknown): void;
123
+ }
124
+ /**
125
+ * Two triggers inside this many ms are one read. Switching to a tab raises both
126
+ * `visibilitychange` and `focus`, and one read is enough for it.
127
+ */
128
+ export declare const DEFAULT_MERGE_MS = 1000;
129
+ export interface RefreshSchedulerOptions {
130
+ document: VisibilityTarget;
131
+ window: FocusTarget;
132
+ /** Scheduled refreshes skip while this is paused. */
133
+ budget?: Pick<RelayBudget, 'pausedForMs'>;
134
+ /** Default {@link DEFAULT_MERGE_MS}. */
135
+ mergeMs?: number;
136
+ /** A subscriber's interval when it names none. Default 30 s. */
137
+ intervalMs?: number;
138
+ clock?: SchedulerClock;
139
+ }
140
+ export interface RefreshSubscription {
141
+ /**
142
+ * Whether the periodic refresh runs. Default `true`.
143
+ *
144
+ * A caller with live delivery turns it off while its socket is live and keeps
145
+ * the focus and visibility triggers: a tab hidden for ten minutes may have had
146
+ * its socket dropped by the browser, and a reconnect's backfill is not
147
+ * instant, so re-reading when somebody looks is right either way.
148
+ */
149
+ interval?: boolean;
150
+ intervalMs?: number;
151
+ }
152
+ export interface RefreshScheduler {
153
+ /**
154
+ * Re-run `refresh` on focus, on becoming visible, and every `intervalMs`
155
+ * while visible. Never on subscribe — the caller owns its first read.
156
+ * Every subscriber on one `intervalMs` shares one timer and wakes in the same
157
+ * tick; the first scheduled refresh comes 0.5–1.5 intervals after subscribing.
158
+ * Returns an unsubscribe; calling it twice is harmless.
159
+ */
160
+ subscribe(refresh: () => void | Promise<void>, options?: RefreshSubscription): () => void;
161
+ }
162
+ export declare function createRefreshScheduler(options: RefreshSchedulerOptions): RefreshScheduler;
163
+ //# sourceMappingURL=refresh.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refresh.d.ts","sourceRoot":"","sources":["../src/refresh.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,258 @@
1
+ /**
2
+ * When a tab re-reads, and when it must not (PER-13).
3
+ *
4
+ * Buzz meters `POST /query` at **300 a minute per pubkey** — a fixed 60-second
5
+ * window, `human_api_calls_per_min`, applied once per HTTP call before the
6
+ * filters are parsed (`enforce_http_admission`, `api/bridge.rs`). Not per app
7
+ * and not per tab: two Peek tabs and a Ship tab signed in as one person share
8
+ * one allowance, and the relay is the only participant that can see all three.
9
+ *
10
+ * Ship and Peek each grew half of the answer. Ship's `useRefresh` merged the two
11
+ * events a tab switch raises and held off after a 429; Peek's
12
+ * `useRefreshTriggers` did neither, so a tab switch fired every read twice, and
13
+ * each refused read waited out its own hint and then retried at the same
14
+ * instant as every other one. Measured on production on 2026-09-23: Miky's key
15
+ * at 306 requests in one minute, single seconds of 40–58, and 33 refusals in
16
+ * one second when a whole fan-out was sent into a pause already in force.
17
+ *
18
+ * ## Two objects, both handed to the app
19
+ *
20
+ * - {@link createRelayBudget} — how long the relay has asked this tab to wait.
21
+ * **The transport writes it** (it sees every 429) and everything that decides
22
+ * whether to read consults it. No reader has to know about quotas.
23
+ * - {@link createRefreshScheduler} — one `visibilitychange` and one `focus`
24
+ * listener for the whole tab, fanning out to every subscriber, each of which
25
+ * still gets Ship's merge: two triggers inside `mergeMs` are one read.
26
+ *
27
+ * Neither is a module-level singleton (ADR 0002 §10 constraint 2): the app
28
+ * builds one of each and keeps the binding, as it does the live-client holder.
29
+ *
30
+ * ## A person's own action is not paused
31
+ *
32
+ * Nothing here gates a read. The budget is advice the *cadence* honours —
33
+ * scheduled refreshes skip while it is in force, and a retry waits it out — so
34
+ * a read somebody asked for by clicking still goes out at once. Backing off a
35
+ * poll costs freshness nobody asked for; backing off a click costs the thing
36
+ * they just did.
37
+ */
38
+ /**
39
+ * The host's timers, declared here because this package compiles with neither
40
+ * the DOM nor the Node lib. Looked up on every call, so a test's fake timers
41
+ * apply to the defaults.
42
+ */
43
+ const host = globalThis;
44
+ /** The relay's quota refusal, as opposed to any other failure. */
45
+ export function isRateLimited(text) {
46
+ const lower = text.toLowerCase();
47
+ return lower.includes('rate-limited') || lower.includes('quota exceeded');
48
+ }
49
+ /**
50
+ * The relay's own wait, in ms, or `undefined` if it did not state one.
51
+ *
52
+ * Buzz writes `rate-limited: quota exceeded; retry in 8s` (`api/bridge.rs`,
53
+ * `connection.rs`). Parsed rather than assumed because the number is what is
54
+ * left of the relay's *window*, not a fixed penalty — it shrinks as the minute
55
+ * rolls on, so a flat wait either spends early or idles long.
56
+ */
57
+ export function retryHintMs(reason) {
58
+ const found = /retry in (\d+)\s*s/i.exec(reason);
59
+ if (!found)
60
+ return undefined;
61
+ return Number(found[1]) * 1000;
62
+ }
63
+ /** How long to wait when the relay refuses without saying for how long. */
64
+ export const DEFAULT_BACKOFF_MS = 30_000;
65
+ /**
66
+ * The floor under a stated wait. `retry in 0s` means the window has just rolled
67
+ * over; the read that would go out now is the one that was refused, so it still
68
+ * waits a moment rather than none.
69
+ */
70
+ export const MIN_BACKOFF_MS = 1_000;
71
+ const CHANNEL_MESSAGE = 'estiva-relay-budget';
72
+ /** Whether a message on the shared channel is one of ours, and until when. */
73
+ function pausedUntilOf(data) {
74
+ if (typeof data !== 'object' || data === null)
75
+ return undefined;
76
+ const message = data;
77
+ if (message.type !== CHANNEL_MESSAGE || typeof message.until !== 'number')
78
+ return undefined;
79
+ return Number.isFinite(message.until) ? message.until : undefined;
80
+ }
81
+ export function createRelayBudget(options = {}) {
82
+ const now = options.now ?? (() => Date.now());
83
+ const sleep = options.sleep ?? ((ms) => new Promise((done) => void host.setTimeout(done, ms)));
84
+ const defaultBackoffMs = options.defaultBackoffMs ?? DEFAULT_BACKOFF_MS;
85
+ const channel = options.channel;
86
+ let pausedUntil = 0;
87
+ const extend = (until) => {
88
+ if (until <= pausedUntil)
89
+ return false;
90
+ pausedUntil = until;
91
+ return true;
92
+ };
93
+ const onMessage = (event) => {
94
+ const until = pausedUntilOf(event.data);
95
+ // Epoch ms, so another tab's clock is this tab's clock. Only extends: a
96
+ // peer's shorter pause must not cancel a longer one learned here.
97
+ if (until !== undefined)
98
+ extend(until);
99
+ };
100
+ channel?.addEventListener('message', onMessage);
101
+ const pausedForMs = () => Math.max(0, pausedUntil - now());
102
+ return {
103
+ noteRateLimited(reason) {
104
+ const wait = Math.max(MIN_BACKOFF_MS, retryHintMs(reason) ?? defaultBackoffMs);
105
+ const until = now() + wait;
106
+ if (extend(until)) {
107
+ try {
108
+ channel?.postMessage({ type: CHANNEL_MESSAGE, until });
109
+ }
110
+ catch {
111
+ /* a closed channel only means the other tabs learn it themselves */
112
+ }
113
+ }
114
+ return pausedForMs();
115
+ },
116
+ pausedForMs,
117
+ async whenClear() {
118
+ for (let wait = pausedForMs(); wait > 0; wait = pausedForMs())
119
+ await sleep(wait);
120
+ },
121
+ reset() {
122
+ pausedUntil = 0;
123
+ },
124
+ close() {
125
+ channel?.removeEventListener('message', onMessage);
126
+ },
127
+ };
128
+ }
129
+ /**
130
+ * Two triggers inside this many ms are one read. Switching to a tab raises both
131
+ * `visibilitychange` and `focus`, and one read is enough for it.
132
+ */
133
+ export const DEFAULT_MERGE_MS = 1_000;
134
+ const DEFAULT_INTERVAL_MS = 30_000;
135
+ export function createRefreshScheduler(options) {
136
+ const { document, window, budget } = options;
137
+ const mergeMs = options.mergeMs ?? DEFAULT_MERGE_MS;
138
+ const defaultIntervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
139
+ const clock = options.clock ?? {
140
+ // Read through at call time, not captured, so a test's fake timers apply.
141
+ now: () => Date.now(),
142
+ setInterval: (run, ms) => host.setInterval(run, ms),
143
+ clearInterval: (handle) => host.clearInterval(handle),
144
+ };
145
+ const subscribers = new Set();
146
+ const groups = new Map();
147
+ let attached = false;
148
+ const visible = () => document.visibilityState === 'visible';
149
+ const tick = (subscriber) => {
150
+ const now = clock.now();
151
+ if (now - subscriber.lastAt < mergeMs)
152
+ return;
153
+ // The relay has refused on quota and said how long to wait. Skipping is the
154
+ // whole backoff: `lastAt` is left alone, so the first trigger after the
155
+ // pause runs at once rather than waiting out another interval — and nothing
156
+ // is queued, so the pause does not end in a burst of everything it skipped.
157
+ if (budget && budget.pausedForMs() > 0)
158
+ return;
159
+ subscriber.lastAt = now;
160
+ try {
161
+ // A rejected refresh is the caller's to report; it must not become an
162
+ // unhandled rejection, and one caller throwing must not skip the rest.
163
+ void Promise.resolve(subscriber.refresh()).catch(() => { });
164
+ }
165
+ catch {
166
+ /* a subscriber's own problem */
167
+ }
168
+ };
169
+ const fanOut = () => {
170
+ for (const subscriber of [...subscribers])
171
+ tick(subscriber);
172
+ };
173
+ const onVisibility = () => {
174
+ if (visible())
175
+ fanOut();
176
+ };
177
+ /*
178
+ One timer per interval, not per subscriber (PER-6).
179
+
180
+ 0.3.0 gave each subscriber its own `setInterval`, started when it
181
+ subscribed. Three views on one page, each on the same 10 s, therefore woke
182
+ at three unrelated phases — a read every three seconds or so, none of them
183
+ close enough to merge — and a page with a widget per section read "every
184
+ couple of seconds" at an interval nobody had chosen. Sharing the timer
185
+ puts every subscriber on an interval into the same wake-up.
186
+
187
+ A subscriber that joined less than half an interval ago sits the tick out:
188
+ its caller has just done its own first read, and a shared phase would
189
+ otherwise re-read it moments later. So the first scheduled refresh comes
190
+ between a half and one and a half intervals after subscribing, and every
191
+ one after that on the shared beat.
192
+ */
193
+ const intervalTick = (group, intervalMs) => {
194
+ // Checked at fire time rather than started and stopped on every
195
+ // visibility change: a hidden tab has nothing on screen to be stale.
196
+ if (!visible())
197
+ return;
198
+ const now = clock.now();
199
+ for (const subscriber of [...group.members]) {
200
+ if (now - subscriber.joinedAt < intervalMs / 2)
201
+ continue;
202
+ tick(subscriber);
203
+ }
204
+ };
205
+ const join = (subscriber, intervalMs) => {
206
+ let group = groups.get(intervalMs);
207
+ if (!group) {
208
+ const created = { members: new Set(), timer: undefined };
209
+ created.timer = clock.setInterval(() => intervalTick(created, intervalMs), intervalMs);
210
+ groups.set(intervalMs, created);
211
+ group = created;
212
+ }
213
+ group.members.add(subscriber);
214
+ };
215
+ const leave = (subscriber) => {
216
+ if (subscriber.intervalMs === undefined)
217
+ return;
218
+ const group = groups.get(subscriber.intervalMs);
219
+ if (!group)
220
+ return;
221
+ group.members.delete(subscriber);
222
+ if (group.members.size === 0) {
223
+ clock.clearInterval(group.timer);
224
+ groups.delete(subscriber.intervalMs);
225
+ }
226
+ };
227
+ return {
228
+ subscribe(refresh, { interval = true, intervalMs = defaultIntervalMs } = {}) {
229
+ const subscriber = {
230
+ refresh,
231
+ lastAt: 0,
232
+ joinedAt: clock.now(),
233
+ intervalMs: interval ? intervalMs : undefined,
234
+ };
235
+ subscribers.add(subscriber);
236
+ if (interval)
237
+ join(subscriber, intervalMs);
238
+ if (!attached) {
239
+ // On first use and never at module scope, so importing registers
240
+ // nothing — and one pair of listeners however many subscribe.
241
+ document.addEventListener('visibilitychange', onVisibility);
242
+ window.addEventListener('focus', fanOut);
243
+ attached = true;
244
+ }
245
+ return () => {
246
+ if (!subscribers.delete(subscriber))
247
+ return;
248
+ leave(subscriber);
249
+ if (subscribers.size === 0 && attached) {
250
+ document.removeEventListener('visibilitychange', onVisibility);
251
+ window.removeEventListener('focus', fanOut);
252
+ attached = false;
253
+ }
254
+ };
255
+ },
256
+ };
257
+ }
258
+ //# sourceMappingURL=refresh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refresh.js","sourceRoot":"","sources":["../src/refresh.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@estiva-app/platform",
3
- "version": "0.2.0",
4
- "description": "Browser lifecycle two Estiva apps share: the one live relay socket a tab should hold, its credential read per connect, and the network coming back.",
3
+ "version": "0.3.1",
4
+ "description": "Browser lifecycle two Estiva apps share: the one live relay socket a tab should hold, its credential read per connect, the network coming back, and when a tab re-reads.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
package/src/index.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * and the two relay clients, and deliberately contains no fold. This package
6
6
  * sits above it and holds the things that are about *running in a tab* — the
7
7
  * one socket a tab should have, the credential read per connect, the network
8
- * coming back, and (SHA-2) the PWA's manifest, service worker and update flow.
8
+ * coming back, when a tab re-reads and when the relay has asked it to wait
9
+ * (PER-13), and (SHA-2) the PWA's manifest, service worker and update flow.
9
10
  *
10
11
  * ## The rule this package is here to keep
11
12
  *
@@ -34,3 +35,21 @@ export {
34
35
  type FolderListener,
35
36
  } from './folder-activity.js'
36
37
  export { assumeOnline, browserOnlineSource, type OnlineSource, type OnlineTarget } from './online.js'
38
+ export {
39
+ DEFAULT_BACKOFF_MS,
40
+ DEFAULT_MERGE_MS,
41
+ MIN_BACKOFF_MS,
42
+ createRefreshScheduler,
43
+ createRelayBudget,
44
+ isRateLimited,
45
+ retryHintMs,
46
+ type BudgetChannel,
47
+ type FocusTarget,
48
+ type RefreshScheduler,
49
+ type RefreshSchedulerOptions,
50
+ type RefreshSubscription,
51
+ type RelayBudget,
52
+ type RelayBudgetOptions,
53
+ type SchedulerClock,
54
+ type VisibilityTarget,
55
+ } from './refresh.js'
package/src/refresh.ts ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * When a tab re-reads, and when it must not (PER-13).
3
+ *
4
+ * Buzz meters `POST /query` at **300 a minute per pubkey** — a fixed 60-second
5
+ * window, `human_api_calls_per_min`, applied once per HTTP call before the
6
+ * filters are parsed (`enforce_http_admission`, `api/bridge.rs`). Not per app
7
+ * and not per tab: two Peek tabs and a Ship tab signed in as one person share
8
+ * one allowance, and the relay is the only participant that can see all three.
9
+ *
10
+ * Ship and Peek each grew half of the answer. Ship's `useRefresh` merged the two
11
+ * events a tab switch raises and held off after a 429; Peek's
12
+ * `useRefreshTriggers` did neither, so a tab switch fired every read twice, and
13
+ * each refused read waited out its own hint and then retried at the same
14
+ * instant as every other one. Measured on production on 2026-09-23: Miky's key
15
+ * at 306 requests in one minute, single seconds of 40–58, and 33 refusals in
16
+ * one second when a whole fan-out was sent into a pause already in force.
17
+ *
18
+ * ## Two objects, both handed to the app
19
+ *
20
+ * - {@link createRelayBudget} — how long the relay has asked this tab to wait.
21
+ * **The transport writes it** (it sees every 429) and everything that decides
22
+ * whether to read consults it. No reader has to know about quotas.
23
+ * - {@link createRefreshScheduler} — one `visibilitychange` and one `focus`
24
+ * listener for the whole tab, fanning out to every subscriber, each of which
25
+ * still gets Ship's merge: two triggers inside `mergeMs` are one read.
26
+ *
27
+ * Neither is a module-level singleton (ADR 0002 §10 constraint 2): the app
28
+ * builds one of each and keeps the binding, as it does the live-client holder.
29
+ *
30
+ * ## A person's own action is not paused
31
+ *
32
+ * Nothing here gates a read. The budget is advice the *cadence* honours —
33
+ * scheduled refreshes skip while it is in force, and a retry waits it out — so
34
+ * a read somebody asked for by clicking still goes out at once. Backing off a
35
+ * poll costs freshness nobody asked for; backing off a click costs the thing
36
+ * they just did.
37
+ */
38
+
39
+ /**
40
+ * The host's timers, declared here because this package compiles with neither
41
+ * the DOM nor the Node lib. Looked up on every call, so a test's fake timers
42
+ * apply to the defaults.
43
+ */
44
+ const host = globalThis as unknown as {
45
+ setTimeout(run: () => void, ms: number): unknown
46
+ setInterval(run: () => void, ms: number): unknown
47
+ clearInterval(handle: unknown): void
48
+ }
49
+
50
+ /** The relay's quota refusal, as opposed to any other failure. */
51
+ export function isRateLimited(text: string): boolean {
52
+ const lower = text.toLowerCase()
53
+ return lower.includes('rate-limited') || lower.includes('quota exceeded')
54
+ }
55
+
56
+ /**
57
+ * The relay's own wait, in ms, or `undefined` if it did not state one.
58
+ *
59
+ * Buzz writes `rate-limited: quota exceeded; retry in 8s` (`api/bridge.rs`,
60
+ * `connection.rs`). Parsed rather than assumed because the number is what is
61
+ * left of the relay's *window*, not a fixed penalty — it shrinks as the minute
62
+ * rolls on, so a flat wait either spends early or idles long.
63
+ */
64
+ export function retryHintMs(reason: string): number | undefined {
65
+ const found = /retry in (\d+)\s*s/i.exec(reason)
66
+ if (!found) return undefined
67
+ return Number(found[1]) * 1000
68
+ }
69
+
70
+ /** How long to wait when the relay refuses without saying for how long. */
71
+ export const DEFAULT_BACKOFF_MS = 30_000
72
+
73
+ /**
74
+ * The floor under a stated wait. `retry in 0s` means the window has just rolled
75
+ * over; the read that would go out now is the one that was refused, so it still
76
+ * waits a moment rather than none.
77
+ */
78
+ export const MIN_BACKOFF_MS = 1_000
79
+
80
+ /** What the budget needs of a `BroadcastChannel`. Declared here, see `online.ts`. */
81
+ export interface BudgetChannel {
82
+ postMessage(message: unknown): void
83
+ addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void
84
+ removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void
85
+ }
86
+
87
+ export interface RelayBudgetOptions {
88
+ /** Default {@link DEFAULT_BACKOFF_MS}. */
89
+ defaultBackoffMs?: number
90
+ /**
91
+ * Share pauses with the app's other tabs.
92
+ *
93
+ * Tabs of one origin signed in as one person spend one allowance, so a pause
94
+ * one of them learns holds for all of them. Pass a `BroadcastChannel` — the
95
+ * app names it — or leave it out. A tab of another app on another origin
96
+ * cannot be reached this way and learns its own pause from its own refusal.
97
+ */
98
+ channel?: BudgetChannel
99
+ now?: () => number
100
+ sleep?: (ms: number) => Promise<void>
101
+ }
102
+
103
+ export interface RelayBudget {
104
+ /**
105
+ * Record a refusal from the relay's own words. Returns the pause in force
106
+ * afterwards, in ms.
107
+ *
108
+ * Extends a pause but never shortens one: two reads refused in the same
109
+ * second carry different remaining windows, and the second answer must not
110
+ * cancel the first.
111
+ */
112
+ noteRateLimited(reason: string): number
113
+ /** Milliseconds left to wait, or 0. */
114
+ pausedForMs(): number
115
+ /**
116
+ * Resolves once no pause is in force — immediately if none is.
117
+ *
118
+ * Loops rather than sleeping once: a pause can be extended while somebody is
119
+ * waiting on it, by this tab or another.
120
+ */
121
+ whenClear(): Promise<void>
122
+ /** Test seam. Nothing in an app clears a pause early. */
123
+ reset(): void
124
+ /** Detach from the channel, if one was given. */
125
+ close(): void
126
+ }
127
+
128
+ const CHANNEL_MESSAGE = 'estiva-relay-budget'
129
+
130
+ /** Whether a message on the shared channel is one of ours, and until when. */
131
+ function pausedUntilOf(data: unknown): number | undefined {
132
+ if (typeof data !== 'object' || data === null) return undefined
133
+ const message = data as { type?: unknown; until?: unknown }
134
+ if (message.type !== CHANNEL_MESSAGE || typeof message.until !== 'number') return undefined
135
+ return Number.isFinite(message.until) ? message.until : undefined
136
+ }
137
+
138
+ export function createRelayBudget(options: RelayBudgetOptions = {}): RelayBudget {
139
+ const now = options.now ?? (() => Date.now())
140
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((done) => void host.setTimeout(done, ms)))
141
+ const defaultBackoffMs = options.defaultBackoffMs ?? DEFAULT_BACKOFF_MS
142
+ const channel = options.channel
143
+ let pausedUntil = 0
144
+
145
+ const extend = (until: number): boolean => {
146
+ if (until <= pausedUntil) return false
147
+ pausedUntil = until
148
+ return true
149
+ }
150
+ const onMessage = (event: { data: unknown }) => {
151
+ const until = pausedUntilOf(event.data)
152
+ // Epoch ms, so another tab's clock is this tab's clock. Only extends: a
153
+ // peer's shorter pause must not cancel a longer one learned here.
154
+ if (until !== undefined) extend(until)
155
+ }
156
+ channel?.addEventListener('message', onMessage)
157
+
158
+ const pausedForMs = () => Math.max(0, pausedUntil - now())
159
+
160
+ return {
161
+ noteRateLimited(reason) {
162
+ const wait = Math.max(MIN_BACKOFF_MS, retryHintMs(reason) ?? defaultBackoffMs)
163
+ const until = now() + wait
164
+ if (extend(until)) {
165
+ try {
166
+ channel?.postMessage({ type: CHANNEL_MESSAGE, until })
167
+ } catch {
168
+ /* a closed channel only means the other tabs learn it themselves */
169
+ }
170
+ }
171
+ return pausedForMs()
172
+ },
173
+ pausedForMs,
174
+ async whenClear() {
175
+ for (let wait = pausedForMs(); wait > 0; wait = pausedForMs()) await sleep(wait)
176
+ },
177
+ reset() {
178
+ pausedUntil = 0
179
+ },
180
+ close() {
181
+ channel?.removeEventListener('message', onMessage)
182
+ },
183
+ }
184
+ }
185
+
186
+ /** What the scheduler needs of `document`. */
187
+ export interface VisibilityTarget {
188
+ readonly visibilityState: string
189
+ addEventListener(type: 'visibilitychange', listener: () => void): void
190
+ removeEventListener(type: 'visibilitychange', listener: () => void): void
191
+ }
192
+
193
+ /** What the scheduler needs of `window`. */
194
+ export interface FocusTarget {
195
+ addEventListener(type: 'focus', listener: () => void): void
196
+ removeEventListener(type: 'focus', listener: () => void): void
197
+ }
198
+
199
+ /** Timers, injected so a test can drive them. Handles are opaque. */
200
+ export interface SchedulerClock {
201
+ now(): number
202
+ setInterval(run: () => void, ms: number): unknown
203
+ clearInterval(handle: unknown): void
204
+ }
205
+
206
+ /**
207
+ * Two triggers inside this many ms are one read. Switching to a tab raises both
208
+ * `visibilitychange` and `focus`, and one read is enough for it.
209
+ */
210
+ export const DEFAULT_MERGE_MS = 1_000
211
+
212
+ export interface RefreshSchedulerOptions {
213
+ document: VisibilityTarget
214
+ window: FocusTarget
215
+ /** Scheduled refreshes skip while this is paused. */
216
+ budget?: Pick<RelayBudget, 'pausedForMs'>
217
+ /** Default {@link DEFAULT_MERGE_MS}. */
218
+ mergeMs?: number
219
+ /** A subscriber's interval when it names none. Default 30 s. */
220
+ intervalMs?: number
221
+ clock?: SchedulerClock
222
+ }
223
+
224
+ export interface RefreshSubscription {
225
+ /**
226
+ * Whether the periodic refresh runs. Default `true`.
227
+ *
228
+ * A caller with live delivery turns it off while its socket is live and keeps
229
+ * the focus and visibility triggers: a tab hidden for ten minutes may have had
230
+ * its socket dropped by the browser, and a reconnect's backfill is not
231
+ * instant, so re-reading when somebody looks is right either way.
232
+ */
233
+ interval?: boolean
234
+ intervalMs?: number
235
+ }
236
+
237
+ export interface RefreshScheduler {
238
+ /**
239
+ * Re-run `refresh` on focus, on becoming visible, and every `intervalMs`
240
+ * while visible. Never on subscribe — the caller owns its first read.
241
+ * Every subscriber on one `intervalMs` shares one timer and wakes in the same
242
+ * tick; the first scheduled refresh comes 0.5–1.5 intervals after subscribing.
243
+ * Returns an unsubscribe; calling it twice is harmless.
244
+ */
245
+ subscribe(refresh: () => void | Promise<void>, options?: RefreshSubscription): () => void
246
+ }
247
+
248
+ const DEFAULT_INTERVAL_MS = 30_000
249
+
250
+ interface Subscriber {
251
+ refresh: () => void | Promise<void>
252
+ /** When this subscriber last ran. Merge and pause are judged per subscriber. */
253
+ lastAt: number
254
+ /** When it subscribed — its caller's own first read, which this never sees. */
255
+ joinedAt: number
256
+ /** The interval it ticks on, or `undefined` for focus and visibility only. */
257
+ intervalMs: number | undefined
258
+ }
259
+
260
+ /** Every subscriber on one interval, and the one timer they share (PER-6). */
261
+ interface IntervalGroup {
262
+ members: Set<Subscriber>
263
+ timer: unknown
264
+ }
265
+
266
+ export function createRefreshScheduler(options: RefreshSchedulerOptions): RefreshScheduler {
267
+ const { document, window, budget } = options
268
+ const mergeMs = options.mergeMs ?? DEFAULT_MERGE_MS
269
+ const defaultIntervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
270
+ const clock: SchedulerClock = options.clock ?? {
271
+ // Read through at call time, not captured, so a test's fake timers apply.
272
+ now: () => Date.now(),
273
+ setInterval: (run, ms) => host.setInterval(run, ms),
274
+ clearInterval: (handle) => host.clearInterval(handle),
275
+ }
276
+ const subscribers = new Set<Subscriber>()
277
+ const groups = new Map<number, IntervalGroup>()
278
+ let attached = false
279
+
280
+ const visible = () => document.visibilityState === 'visible'
281
+
282
+ const tick = (subscriber: Subscriber) => {
283
+ const now = clock.now()
284
+ if (now - subscriber.lastAt < mergeMs) return
285
+ // The relay has refused on quota and said how long to wait. Skipping is the
286
+ // whole backoff: `lastAt` is left alone, so the first trigger after the
287
+ // pause runs at once rather than waiting out another interval — and nothing
288
+ // is queued, so the pause does not end in a burst of everything it skipped.
289
+ if (budget && budget.pausedForMs() > 0) return
290
+ subscriber.lastAt = now
291
+ try {
292
+ // A rejected refresh is the caller's to report; it must not become an
293
+ // unhandled rejection, and one caller throwing must not skip the rest.
294
+ void Promise.resolve(subscriber.refresh()).catch(() => {})
295
+ } catch {
296
+ /* a subscriber's own problem */
297
+ }
298
+ }
299
+ const fanOut = () => {
300
+ for (const subscriber of [...subscribers]) tick(subscriber)
301
+ }
302
+ const onVisibility = () => {
303
+ if (visible()) fanOut()
304
+ }
305
+
306
+ /*
307
+ One timer per interval, not per subscriber (PER-6).
308
+
309
+ 0.3.0 gave each subscriber its own `setInterval`, started when it
310
+ subscribed. Three views on one page, each on the same 10 s, therefore woke
311
+ at three unrelated phases — a read every three seconds or so, none of them
312
+ close enough to merge — and a page with a widget per section read "every
313
+ couple of seconds" at an interval nobody had chosen. Sharing the timer
314
+ puts every subscriber on an interval into the same wake-up.
315
+
316
+ A subscriber that joined less than half an interval ago sits the tick out:
317
+ its caller has just done its own first read, and a shared phase would
318
+ otherwise re-read it moments later. So the first scheduled refresh comes
319
+ between a half and one and a half intervals after subscribing, and every
320
+ one after that on the shared beat.
321
+ */
322
+ const intervalTick = (group: IntervalGroup, intervalMs: number) => {
323
+ // Checked at fire time rather than started and stopped on every
324
+ // visibility change: a hidden tab has nothing on screen to be stale.
325
+ if (!visible()) return
326
+ const now = clock.now()
327
+ for (const subscriber of [...group.members]) {
328
+ if (now - subscriber.joinedAt < intervalMs / 2) continue
329
+ tick(subscriber)
330
+ }
331
+ }
332
+ const join = (subscriber: Subscriber, intervalMs: number) => {
333
+ let group = groups.get(intervalMs)
334
+ if (!group) {
335
+ const created: IntervalGroup = { members: new Set(), timer: undefined }
336
+ created.timer = clock.setInterval(() => intervalTick(created, intervalMs), intervalMs)
337
+ groups.set(intervalMs, created)
338
+ group = created
339
+ }
340
+ group.members.add(subscriber)
341
+ }
342
+ const leave = (subscriber: Subscriber) => {
343
+ if (subscriber.intervalMs === undefined) return
344
+ const group = groups.get(subscriber.intervalMs)
345
+ if (!group) return
346
+ group.members.delete(subscriber)
347
+ if (group.members.size === 0) {
348
+ clock.clearInterval(group.timer)
349
+ groups.delete(subscriber.intervalMs)
350
+ }
351
+ }
352
+
353
+ return {
354
+ subscribe(refresh, { interval = true, intervalMs = defaultIntervalMs } = {}) {
355
+ const subscriber: Subscriber = {
356
+ refresh,
357
+ lastAt: 0,
358
+ joinedAt: clock.now(),
359
+ intervalMs: interval ? intervalMs : undefined,
360
+ }
361
+ subscribers.add(subscriber)
362
+ if (interval) join(subscriber, intervalMs)
363
+ if (!attached) {
364
+ // On first use and never at module scope, so importing registers
365
+ // nothing — and one pair of listeners however many subscribe.
366
+ document.addEventListener('visibilitychange', onVisibility)
367
+ window.addEventListener('focus', fanOut)
368
+ attached = true
369
+ }
370
+ return () => {
371
+ if (!subscribers.delete(subscriber)) return
372
+ leave(subscriber)
373
+ if (subscribers.size === 0 && attached) {
374
+ document.removeEventListener('visibilitychange', onVisibility)
375
+ window.removeEventListener('focus', fanOut)
376
+ attached = false
377
+ }
378
+ }
379
+ },
380
+ }
381
+ }