@estiva-app/platform 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @estiva-app/platform
2
2
 
3
+ ## 0.3.0
4
+
5
+ When a tab re-reads, and when the relay has asked it to wait (PER-13). Additive.
6
+
7
+ Ship's `useRefresh` + `relayBudget` taken out of the app, so Peek can stop
8
+ firing every read twice per tab switch and stop retrying refused reads in a
9
+ herd. On 2026-09-23 one person's key reached 306 requests in a minute across
10
+ two Peek tabs and a Ship tab, against buzz's 300-per-pubkey window.
11
+
12
+ - `createRefreshScheduler({ document, window, budget })` — one `focus` and one
13
+ `visibilitychange` listener per tab, fanned out to every subscriber. Two
14
+ triggers inside `mergeMs` (default 1 s) are one read, per subscriber. The
15
+ interval never runs in a hidden tab. Nothing runs while the budget is paused,
16
+ and nothing is queued for when it ends.
17
+ - `createRelayBudget({ channel? })` — the pause the relay's `retry in N s`
18
+ names. Extends, never shortens. `whenClear()` resolves once it has passed.
19
+ Given a `BroadcastChannel`, a pause one tab learns holds in the app's others.
20
+ - `isRateLimited`, `retryHintMs`, `DEFAULT_BACKOFF_MS`, `MIN_BACKOFF_MS`,
21
+ `DEFAULT_MERGE_MS`.
22
+
23
+ ## 0.2.0
24
+
25
+ A Folder notification may carry the event that caused it (PER-8). Additive:
26
+ every existing listener keeps compiling and behaving as before.
27
+
28
+ - `watchFolders`' listener is called `(folder, event)`. The wide filter always
29
+ had the event in hand and dropped it, so a reader that keeps what it has read
30
+ — Peek's sidebar, which re-read 90 days of every Folder to learn about the one
31
+ message the socket had just delivered — had nothing to apply and could only
32
+ re-read, against a relay quota metered per HTTP request.
33
+ - `watchFolders` takes `since`, bounding what the REQ replays on subscribe and
34
+ on every reconnect. Without it the relay replays up to its default limit,
35
+ shared across every Folder in the filter.
36
+ - `FolderListener` is `(event?) => void` and `FolderActivity.notify` takes the
37
+ event. A notification with nothing to carry — another app's record moved —
38
+ still sends none, so a missing event means "re-read", never "nothing".
39
+
3
40
  ## 0.1.0
4
41
 
5
42
  First release. The plumbing a second consumer of the live socket needs, taken
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
@@ -23,11 +23,26 @@
23
23
  * reader watching its own Folders is in the same position mechanically and the
24
24
  * word stops fitting, so the shared name is about the Folder rather than about
25
25
  * whose records are in it.
26
+ *
27
+ * ## …unless it has one to give (PER-8)
28
+ *
29
+ * "Re-read now" was the right signal while nobody held what they had read. Once
30
+ * a reader keeps the events it already has — Peek's sidebar re-read 90 days of
31
+ * every Folder to learn about the one message the socket had just delivered, and
32
+ * spent the relay's per-request HTTP quota doing it — the event is the whole
33
+ * point. So a notification *may* carry the event that caused it. A listener
34
+ * that ignores the argument is exactly the listener it was before, and a
35
+ * notification with nothing to carry (another app's record moved) still sends
36
+ * none, so a reader must treat a missing event as "re-read", not as "nothing".
26
37
  */
27
- export type FolderListener = () => void;
38
+ import type { SignedEvent } from '@estiva-app/protocol';
39
+ export type FolderListener = (event?: SignedEvent) => void;
28
40
  export interface FolderActivity {
29
- /** Say that something changed in `folder`. Safe for a folder nobody watches. */
30
- notify(folder: string): void;
41
+ /**
42
+ * Say that something changed in `folder`, and hand over the event when there
43
+ * is one. Safe for a folder nobody watches.
44
+ */
45
+ notify(folder: string, event?: SignedEvent): void;
31
46
  /** Watch one folder. Returns an unsubscribe; calling it twice is harmless. */
32
47
  watch(folder: string, listener: FolderListener): () => void;
33
48
  /** Folders with at least one listener. Test and diagnostic seam. */
@@ -1 +1 @@
1
- {"version":3,"file":"folder-activity.d.ts","sourceRoot":"","sources":["../src/folder-activity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,IAAI,CAAA;AAEvC,MAAM,WAAW,cAAc;IAC7B,gFAAgF;IAChF,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,8EAA8E;IAC9E,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,GAAG,MAAM,IAAI,CAAA;IAC3D,oEAAoE;IACpE,OAAO,IAAI,MAAM,EAAE,CAAA;CACpB;AAED,wBAAgB,oBAAoB,IAAI,cAAc,CA8BrD"}
1
+ {"version":3,"file":"folder-activity.d.ts","sourceRoot":"","sources":["../src/folder-activity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAEvD,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,CAAC,EAAE,WAAW,KAAK,IAAI,CAAA;AAE1D,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,CAAA;IACjD,8EAA8E;IAC9E,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,GAAG,MAAM,IAAI,CAAA;IAC3D,oEAAoE;IACpE,OAAO,IAAI,MAAM,EAAE,CAAA;CACpB;AAED,wBAAgB,oBAAoB,IAAI,cAAc,CA8BrD"}
@@ -1,12 +1,12 @@
1
1
  export function createFolderActivity() {
2
2
  const listeners = new Map();
3
3
  return {
4
- notify(folder) {
4
+ notify(folder, event) {
5
5
  for (const listener of listeners.get(folder) ?? []) {
6
6
  // One bad listener must not stop the others, and must not surface
7
7
  // somewhere unrelated as a relay error.
8
8
  try {
9
- listener();
9
+ listener(event);
10
10
  }
11
11
  catch {
12
12
  /* a listener's own problem */
@@ -1 +1 @@
1
- {"version":3,"file":"folder-activity.js","sourceRoot":"","sources":["../src/folder-activity.ts"],"names":[],"mappings":"AAqCA,MAAM,UAAU,oBAAoB;IAClC,MAAM,SAAS,GAAG,IAAI,GAAG,EAA+B,CAAA;IACxD,OAAO;QACL,MAAM,CAAC,MAAM;YACX,KAAK,MAAM,QAAQ,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;gBACnD,kEAAkE;gBAClE,wCAAwC;gBACxC,IAAI,CAAC;oBACH,QAAQ,EAAE,CAAA;gBACZ,CAAC;gBAAC,MAAM,CAAC;oBACP,8BAA8B;gBAChC,CAAC;YACH,CAAC;QACH,CAAC;QACD,KAAK,CAAC,MAAM,EAAE,QAAQ;YACpB,IAAI,SAAS,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;YACrC,IAAI,CAAC,SAAS,EAAE,CAAC;gBACf,SAAS,GAAG,IAAI,GAAG,EAAE,CAAA;gBACrB,SAAS,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;YAClC,CAAC;YACD,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACvB,OAAO,GAAG,EAAE;gBACV,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;gBACrC,IAAI,CAAC,OAAO;oBAAE,OAAM;gBACpB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;gBACxB,IAAI,OAAO,CAAC,IAAI,KAAK,CAAC;oBAAE,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;YAClD,CAAC,CAAA;QACH,CAAC;QACD,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC;KACrC,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"folder-activity.js","sourceRoot":"","sources":["../src/folder-activity.ts"],"names":[],"mappings":"AAqDA,MAAM,UAAU,oBAAoB;IAClC,MAAM,SAAS,GAAG,IAAI,GAAG,EAA+B,CAAA;IACxD,OAAO;QACL,MAAM,CAAC,MAAM,EAAE,KAAK;YAClB,KAAK,MAAM,QAAQ,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;gBACnD,kEAAkE;gBAClE,wCAAwC;gBACxC,IAAI,CAAC;oBACH,QAAQ,CAAC,KAAK,CAAC,CAAA;gBACjB,CAAC;gBAAC,MAAM,CAAC;oBACP,8BAA8B;gBAChC,CAAC;YACH,CAAC;QACH,CAAC;QACD,KAAK,CAAC,MAAM,EAAE,QAAQ;YACpB,IAAI,SAAS,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;YACrC,IAAI,CAAC,SAAS,EAAE,CAAC;gBACf,SAAS,GAAG,IAAI,GAAG,EAAE,CAAA;gBACrB,SAAS,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;YAClC,CAAC;YACD,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACvB,OAAO,GAAG,EAAE;gBACV,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;gBACrC,IAAI,CAAC,OAAO;oBAAE,OAAM;gBACpB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;gBACxB,IAAI,OAAO,CAAC,IAAI,KAAK,CAAC;oBAAE,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;YAClD,CAAC,CAAA;QACH,CAAC;QACD,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC;KACrC,CAAA;AACH,CAAC"}
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"}
@@ -126,9 +126,18 @@ export interface LiveClient {
126
126
  * `kinds` is strongly recommended rather than optional-by-taste: a kindless
127
127
  * wide filter was refused outright on that other build with `restricted:
128
128
  * p-gated events require #p matching your pubkey`.
129
+ *
130
+ * **The listener is handed the event** (PER-8). A reader that keeps what it
131
+ * has read applies it instead of re-reading the Folder; one that only wants
132
+ * the signal ignores the second argument. Stored events arrive as well as
133
+ * live ones — the REQ's replay on subscribe and on every reconnect — so a
134
+ * reader must be idempotent by event id. `since` bounds that replay: without
135
+ * it the relay replays whatever its default limit allows, shared across every
136
+ * Folder in the filter, which a keeping reader then has to dedupe.
129
137
  */
130
- watchFolders(folders: string[], listener: (folder: string) => void, options?: {
138
+ watchFolders(folders: string[], listener: (folder: string, event?: SignedEvent) => void, options?: {
131
139
  kinds?: number[];
140
+ since?: number;
132
141
  }): FolderWatch;
133
142
  /** Watch one Folder by name. The refcounted half of {@link watchFolders}. */
134
143
  onFolderActivity(folder: string, listener: FolderListener): () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"live-client.d.ts","sourceRoot":"","sources":["../src/live-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"live-client.d.ts","sourceRoot":"","sources":["../src/live-client.ts"],"names":[],"mappings":"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"}
@@ -139,11 +139,17 @@ export function createLiveClient(options) {
139
139
  },
140
140
  onFolderActivity: (folder, listener) => activity.watch(folder, listener),
141
141
  watchFolders(folders, listener, watchOptions) {
142
- const unwatch = folders.map((folder) => activity.watch(folder, () => listener(folder)));
143
- const subs = chunk([...new Set(folders)], MAX_FOLDERS_PER_SUBSCRIPTION).map((group) => relay.subscribe([{ ...(watchOptions?.kinds ? { kinds: watchOptions.kinds } : {}), '#h': group }], (event) => {
142
+ const unwatch = folders.map((folder) => activity.watch(folder, (event) => listener(folder, event)));
143
+ const subs = chunk([...new Set(folders)], MAX_FOLDERS_PER_SUBSCRIPTION).map((group) => relay.subscribe([
144
+ {
145
+ ...(watchOptions?.kinds ? { kinds: watchOptions.kinds } : {}),
146
+ '#h': group,
147
+ ...(watchOptions?.since !== undefined ? { since: watchOptions.since } : {}),
148
+ },
149
+ ], (event) => {
144
150
  const folder = folderOf(event);
145
151
  if (folder)
146
- activity.notify(folder);
152
+ activity.notify(folder, event);
147
153
  }, {
148
154
  onClosed: (reason) => options.log?.('folder subscription closed', { reason }),
149
155
  }));
@@ -1 +1 @@
1
- {"version":3,"file":"live-client.js","sourceRoot":"","sources":["../src/live-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,OAAO,EACL,0BAA0B,EAC1B,eAAe,GAQhB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,oBAAoB,EAA4C,MAAM,sBAAsB,CAAA;AACrG,OAAO,EAAE,YAAY,EAAqB,MAAM,aAAa,CAAA;AAE7D;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,GAAG,CAAA;AA+F/C;;;;;;;;GAQG;AACH,SAAS,MAAM,CAAC,GAAe;IAC7B,IAAI,CAAC;QACH,GAAG,EAAE,CAAA;IACP,CAAC;IAAC,MAAM,CAAC;QACP,mDAAmD;IACrD,CAAC;AACH,CAAC;AAED,yFAAyF;AACzF,SAAS,QAAQ,CAAC,KAAkB;IAClC,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;AACtD,CAAC;AAED,SAAS,KAAK,CAAI,KAAU,EAAE,IAAY;IACxC,MAAM,GAAG,GAAU,EAAE,CAAA;IACrB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,IAAI;QAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAA;IAC/E,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,OAA0B;IACzD,MAAM,cAAc,GAAG,IAAI,GAAG,EAA+B,CAAA;IAC7D,MAAM,QAAQ,GAAmB,oBAAoB,EAAE,CAAA;IACvD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,YAAY,EAAE,CAAA;IAC/C,IAAI,KAAK,GAAe,YAAY,CAAA;IAEpC,MAAM,KAAK,GAAG,eAAe,CAAC;QAC5B,GAAG,EAAE,OAAO,CAAC,QAAQ;QACrB,aAAa,EAAE,GAAG,EAAE;YAClB,MAAM,UAAU,GAAG,OAAO,CAAC,aAAa,EAAE,CAAA;YAC1C,IAAI,CAAC,UAAU,EAAE,CAAC;gBAChB,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,mBAAmB,EAAE,EAAE,CAAC,CAAA;gBACtD,OAAO,IAAI,CAAA;YACb,CAAC;YACD,OAAO,UAAU,CAAA;QACnB,CAAC;QACD,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE;YAC9C,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,EAAE,cAAc,CAAC,CAAA;gBAClE,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAA;gBAC3C,OAAO,MAAM,CAAA;YACf,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,kEAAkE;gBAClE,uEAAuE;gBACvE,oCAAoC;gBACpC,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,aAAa,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;gBACrD,MAAM,KAAK,CAAA;YACb,CAAC;QACH,CAAC;QACD,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YAChB,KAAK,GAAG,IAAI,CAAA;YACZ,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC,CAAA;YAC9C,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,cAAc,CAAC;gBAAE,MAAM,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAA;QAC1E,CAAC;QACD,kBAAkB,EAAE,OAAO,CAAC,kBAAkB;QAC9C,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,aAAa,EAAE,OAAO,CAAC,aAAa;QACpC,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,UAAU,EAAE,OAAO,CAAC,UAAU;QAC9B,OAAO,EAAE,OAAO,CAAC,OAAO;KACzB,CAAC,CAAA;IAEF,MAAM,aAAa,GAAG,0BAA0B,CAAC,KAAK,EAAE;QACtD,eAAe,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,0BAA0B,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;KAChG,CAAC,CAAA;IAEF,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAA;IAC1D,MAAM,aAAa,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,EAAE;QAClD,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAA;QACnD,2EAA2E;QAC3E,0EAA0E;QAC1E,mEAAmE;QACnE,KAAK,CAAC,SAAS,EAAE,CAAA;IACnB,CAAC,CAAC,CAAA;IAEF,OAAO;QACL,KAAK;QACL,aAAa;QACb,KAAK,EAAE,GAAG,EAAE,CAAC,KAAK;QAClB,OAAO,CAAC,QAAQ;YACd,cAAc,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YAC5B,OAAO,GAAG,EAAE,CAAC,cAAc,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;QAC9C,CAAC;QACD,gBAAgB,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC;QACxE,YAAY,CAAC,OAAO,EAAE,QAAQ,EAAE,YAAY;YAC1C,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;YACvF,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,EAAE,4BAA4B,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACpF,KAAK,CAAC,SAAS,CACb,CAAC,EAAE,GAAG,CAAC,YAAY,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAChF,CAAC,KAAK,EAAE,EAAE;gBACR,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;gBAC9B,IAAI,MAAM;oBAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;YACrC,CAAC,EACD;gBACE,QAAQ,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,4BAA4B,EAAE,EAAE,MAAM,EAAE,CAAC;aAC9E,CACF,CACF,CAAA;YACD,IAAI,OAAO,GAAG,KAAK,CAAA;YACnB,OAAO;gBACL,IAAI;oBACF,IAAI,OAAO;wBAAE,OAAM;oBACnB,OAAO,GAAG,IAAI,CAAA;oBACd,KAAK,MAAM,GAAG,IAAI,IAAI;wBAAE,GAAG,CAAC,KAAK,EAAE,CAAA;oBACnC,KAAK,MAAM,OAAO,IAAI,OAAO;wBAAE,OAAO,EAAE,CAAA;gBAC1C,CAAC;aACF,CAAA;QACH,CAAC;QACD,KAAK;YACH,aAAa,EAAE,CAAA;YACf,aAAa,CAAC,KAAK,EAAE,CAAA;YACrB,KAAK,CAAC,KAAK,EAAE,CAAA;YACb,cAAc,CAAC,KAAK,EAAE,CAAA;QACxB,CAAC;KACF,CAAA;AACH,CAAC;AAwBD;;;;;;GAMG;AACH,MAAM,UAAU,sBAAsB;IACpC,IAAI,OAAO,GAAoD,IAAI,CAAA;IACnE,OAAO;QACL,GAAG,CAAC,OAAO;YACT,IAAI,OAAO,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,QAAQ;gBAAE,OAAO,OAAO,CAAC,MAAM,CAAA;YAC3E,OAAO,EAAE,MAAM,CAAC,KAAK,EAAE,CAAA;YACvB,OAAO,GAAG,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,gBAAgB,CAAC,OAAO,CAAC,EAAE,CAAA;YAC3E,OAAO,OAAO,CAAC,MAAM,CAAA;QACvB,CAAC;QACD,IAAI,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,IAAI,IAAI;QACnC,KAAK;YACH,OAAO,EAAE,MAAM,CAAC,KAAK,EAAE,CAAA;YACvB,OAAO,GAAG,IAAI,CAAA;QAChB,CAAC;KACF,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"live-client.js","sourceRoot":"","sources":["../src/live-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,161 @@
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
+ * Returns an unsubscribe; calling it twice is harmless.
157
+ */
158
+ subscribe(refresh: () => void | Promise<void>, options?: RefreshSubscription): () => void;
159
+ }
160
+ export declare function createRefreshScheduler(options: RefreshSchedulerOptions): RefreshScheduler;
161
+ //# 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,209 @@
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
+ let attached = false;
147
+ const visible = () => document.visibilityState === 'visible';
148
+ const tick = (subscriber) => {
149
+ const now = clock.now();
150
+ if (now - subscriber.lastAt < mergeMs)
151
+ return;
152
+ // The relay has refused on quota and said how long to wait. Skipping is the
153
+ // whole backoff: `lastAt` is left alone, so the first trigger after the
154
+ // pause runs at once rather than waiting out another interval — and nothing
155
+ // is queued, so the pause does not end in a burst of everything it skipped.
156
+ if (budget && budget.pausedForMs() > 0)
157
+ return;
158
+ subscriber.lastAt = now;
159
+ try {
160
+ // A rejected refresh is the caller's to report; it must not become an
161
+ // unhandled rejection, and one caller throwing must not skip the rest.
162
+ void Promise.resolve(subscriber.refresh()).catch(() => { });
163
+ }
164
+ catch {
165
+ /* a subscriber's own problem */
166
+ }
167
+ };
168
+ const fanOut = () => {
169
+ for (const subscriber of [...subscribers])
170
+ tick(subscriber);
171
+ };
172
+ const onVisibility = () => {
173
+ if (visible())
174
+ fanOut();
175
+ };
176
+ return {
177
+ subscribe(refresh, { interval = true, intervalMs = defaultIntervalMs } = {}) {
178
+ const subscriber = { refresh, lastAt: 0, timer: undefined };
179
+ subscribers.add(subscriber);
180
+ if (interval) {
181
+ // Checked at fire time rather than started and stopped on every
182
+ // visibility change: a hidden tab has nothing on screen to be stale.
183
+ subscriber.timer = clock.setInterval(() => {
184
+ if (visible())
185
+ tick(subscriber);
186
+ }, intervalMs);
187
+ }
188
+ if (!attached) {
189
+ // On first use and never at module scope, so importing registers
190
+ // nothing — and one pair of listeners however many subscribe.
191
+ document.addEventListener('visibilitychange', onVisibility);
192
+ window.addEventListener('focus', fanOut);
193
+ attached = true;
194
+ }
195
+ return () => {
196
+ if (!subscribers.delete(subscriber))
197
+ return;
198
+ if (subscriber.timer !== undefined)
199
+ clock.clearInterval(subscriber.timer);
200
+ if (subscribers.size === 0 && attached) {
201
+ document.removeEventListener('visibilitychange', onVisibility);
202
+ window.removeEventListener('focus', fanOut);
203
+ attached = false;
204
+ }
205
+ };
206
+ },
207
+ };
208
+ }
209
+ //# 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.1.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.0",
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": {
@@ -23,12 +23,28 @@
23
23
  * reader watching its own Folders is in the same position mechanically and the
24
24
  * word stops fitting, so the shared name is about the Folder rather than about
25
25
  * whose records are in it.
26
+ *
27
+ * ## …unless it has one to give (PER-8)
28
+ *
29
+ * "Re-read now" was the right signal while nobody held what they had read. Once
30
+ * a reader keeps the events it already has — Peek's sidebar re-read 90 days of
31
+ * every Folder to learn about the one message the socket had just delivered, and
32
+ * spent the relay's per-request HTTP quota doing it — the event is the whole
33
+ * point. So a notification *may* carry the event that caused it. A listener
34
+ * that ignores the argument is exactly the listener it was before, and a
35
+ * notification with nothing to carry (another app's record moved) still sends
36
+ * none, so a reader must treat a missing event as "re-read", not as "nothing".
26
37
  */
27
- export type FolderListener = () => void
38
+ import type { SignedEvent } from '@estiva-app/protocol'
39
+
40
+ export type FolderListener = (event?: SignedEvent) => void
28
41
 
29
42
  export interface FolderActivity {
30
- /** Say that something changed in `folder`. Safe for a folder nobody watches. */
31
- notify(folder: string): void
43
+ /**
44
+ * Say that something changed in `folder`, and hand over the event when there
45
+ * is one. Safe for a folder nobody watches.
46
+ */
47
+ notify(folder: string, event?: SignedEvent): void
32
48
  /** Watch one folder. Returns an unsubscribe; calling it twice is harmless. */
33
49
  watch(folder: string, listener: FolderListener): () => void
34
50
  /** Folders with at least one listener. Test and diagnostic seam. */
@@ -38,12 +54,12 @@ export interface FolderActivity {
38
54
  export function createFolderActivity(): FolderActivity {
39
55
  const listeners = new Map<string, Set<FolderListener>>()
40
56
  return {
41
- notify(folder) {
57
+ notify(folder, event) {
42
58
  for (const listener of listeners.get(folder) ?? []) {
43
59
  // One bad listener must not stop the others, and must not surface
44
60
  // somewhere unrelated as a relay error.
45
61
  try {
46
- listener()
62
+ listener(event)
47
63
  } catch {
48
64
  /* a listener's own problem */
49
65
  }
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'
@@ -141,11 +141,19 @@ export interface LiveClient {
141
141
  * `kinds` is strongly recommended rather than optional-by-taste: a kindless
142
142
  * wide filter was refused outright on that other build with `restricted:
143
143
  * p-gated events require #p matching your pubkey`.
144
+ *
145
+ * **The listener is handed the event** (PER-8). A reader that keeps what it
146
+ * has read applies it instead of re-reading the Folder; one that only wants
147
+ * the signal ignores the second argument. Stored events arrive as well as
148
+ * live ones — the REQ's replay on subscribe and on every reconnect — so a
149
+ * reader must be idempotent by event id. `since` bounds that replay: without
150
+ * it the relay replays whatever its default limit allows, shared across every
151
+ * Folder in the filter, which a keeping reader then has to dedupe.
144
152
  */
145
153
  watchFolders(
146
154
  folders: string[],
147
- listener: (folder: string) => void,
148
- options?: { kinds?: number[] },
155
+ listener: (folder: string, event?: SignedEvent) => void,
156
+ options?: { kinds?: number[]; since?: number },
149
157
  ): FolderWatch
150
158
  /** Watch one Folder by name. The refcounted half of {@link watchFolders}. */
151
159
  onFolderActivity(folder: string, listener: FolderListener): () => void
@@ -247,13 +255,19 @@ export function createLiveClient(options: LiveClientOptions): LiveClient {
247
255
  },
248
256
  onFolderActivity: (folder, listener) => activity.watch(folder, listener),
249
257
  watchFolders(folders, listener, watchOptions) {
250
- const unwatch = folders.map((folder) => activity.watch(folder, () => listener(folder)))
258
+ const unwatch = folders.map((folder) => activity.watch(folder, (event) => listener(folder, event)))
251
259
  const subs = chunk([...new Set(folders)], MAX_FOLDERS_PER_SUBSCRIPTION).map((group) =>
252
260
  relay.subscribe(
253
- [{ ...(watchOptions?.kinds ? { kinds: watchOptions.kinds } : {}), '#h': group }],
261
+ [
262
+ {
263
+ ...(watchOptions?.kinds ? { kinds: watchOptions.kinds } : {}),
264
+ '#h': group,
265
+ ...(watchOptions?.since !== undefined ? { since: watchOptions.since } : {}),
266
+ },
267
+ ],
254
268
  (event) => {
255
269
  const folder = folderOf(event)
256
- if (folder) activity.notify(folder)
270
+ if (folder) activity.notify(folder, event)
257
271
  },
258
272
  {
259
273
  onClosed: (reason) => options.log?.('folder subscription closed', { reason }),
package/src/refresh.ts ADDED
@@ -0,0 +1,323 @@
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
+ * Returns an unsubscribe; calling it twice is harmless.
242
+ */
243
+ subscribe(refresh: () => void | Promise<void>, options?: RefreshSubscription): () => void
244
+ }
245
+
246
+ const DEFAULT_INTERVAL_MS = 30_000
247
+
248
+ interface Subscriber {
249
+ refresh: () => void | Promise<void>
250
+ /** When this subscriber last ran. Merge and pause are judged per subscriber. */
251
+ lastAt: number
252
+ timer: unknown
253
+ }
254
+
255
+ export function createRefreshScheduler(options: RefreshSchedulerOptions): RefreshScheduler {
256
+ const { document, window, budget } = options
257
+ const mergeMs = options.mergeMs ?? DEFAULT_MERGE_MS
258
+ const defaultIntervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
259
+ const clock: SchedulerClock = options.clock ?? {
260
+ // Read through at call time, not captured, so a test's fake timers apply.
261
+ now: () => Date.now(),
262
+ setInterval: (run, ms) => host.setInterval(run, ms),
263
+ clearInterval: (handle) => host.clearInterval(handle),
264
+ }
265
+ const subscribers = new Set<Subscriber>()
266
+ let attached = false
267
+
268
+ const visible = () => document.visibilityState === 'visible'
269
+
270
+ const tick = (subscriber: Subscriber) => {
271
+ const now = clock.now()
272
+ if (now - subscriber.lastAt < mergeMs) return
273
+ // The relay has refused on quota and said how long to wait. Skipping is the
274
+ // whole backoff: `lastAt` is left alone, so the first trigger after the
275
+ // pause runs at once rather than waiting out another interval — and nothing
276
+ // is queued, so the pause does not end in a burst of everything it skipped.
277
+ if (budget && budget.pausedForMs() > 0) return
278
+ subscriber.lastAt = now
279
+ try {
280
+ // A rejected refresh is the caller's to report; it must not become an
281
+ // unhandled rejection, and one caller throwing must not skip the rest.
282
+ void Promise.resolve(subscriber.refresh()).catch(() => {})
283
+ } catch {
284
+ /* a subscriber's own problem */
285
+ }
286
+ }
287
+ const fanOut = () => {
288
+ for (const subscriber of [...subscribers]) tick(subscriber)
289
+ }
290
+ const onVisibility = () => {
291
+ if (visible()) fanOut()
292
+ }
293
+
294
+ return {
295
+ subscribe(refresh, { interval = true, intervalMs = defaultIntervalMs } = {}) {
296
+ const subscriber: Subscriber = { refresh, lastAt: 0, timer: undefined }
297
+ subscribers.add(subscriber)
298
+ if (interval) {
299
+ // Checked at fire time rather than started and stopped on every
300
+ // visibility change: a hidden tab has nothing on screen to be stale.
301
+ subscriber.timer = clock.setInterval(() => {
302
+ if (visible()) tick(subscriber)
303
+ }, intervalMs)
304
+ }
305
+ if (!attached) {
306
+ // On first use and never at module scope, so importing registers
307
+ // nothing — and one pair of listeners however many subscribe.
308
+ document.addEventListener('visibilitychange', onVisibility)
309
+ window.addEventListener('focus', fanOut)
310
+ attached = true
311
+ }
312
+ return () => {
313
+ if (!subscribers.delete(subscriber)) return
314
+ if (subscriber.timer !== undefined) clock.clearInterval(subscriber.timer)
315
+ if (subscribers.size === 0 && attached) {
316
+ document.removeEventListener('visibilitychange', onVisibility)
317
+ window.removeEventListener('focus', fanOut)
318
+ attached = false
319
+ }
320
+ }
321
+ },
322
+ }
323
+ }