@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 +37 -0
- package/README.md +31 -0
- package/dist/folder-activity.d.ts +18 -3
- package/dist/folder-activity.d.ts.map +1 -1
- package/dist/folder-activity.js +2 -2
- package/dist/folder-activity.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/live-client.d.ts +10 -1
- package/dist/live-client.d.ts.map +1 -1
- package/dist/live-client.js +9 -3
- package/dist/live-client.js.map +1 -1
- package/dist/refresh.d.ts +161 -0
- package/dist/refresh.d.ts.map +1 -0
- package/dist/refresh.js +209 -0
- package/dist/refresh.js.map +1 -0
- package/package.json +2 -2
- package/src/folder-activity.ts +21 -5
- package/src/index.ts +20 -1
- package/src/live-client.ts +19 -5
- package/src/refresh.ts +323 -0
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
|
-
|
|
38
|
+
import type { SignedEvent } from '@estiva-app/protocol';
|
|
39
|
+
export type FolderListener = (event?: SignedEvent) => void;
|
|
28
40
|
export interface FolderActivity {
|
|
29
|
-
/**
|
|
30
|
-
|
|
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
|
|
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"}
|
package/dist/folder-activity.js
CHANGED
|
@@ -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":"
|
|
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,
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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,
|
|
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
|
|
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"}
|
package/dist/live-client.d.ts
CHANGED
|
@@ -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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,OAAO,EAGL,KAAK,oBAAoB,EACzB,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAA;AACrG,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AAE7D;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,MAAM,CAAA;AAE/C;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAA;IACjC,QAAQ,CAAC,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAA;IAChC,yFAAyF;IACzF,mBAAmB,CAAC,IAAI,IAAI,CAAA;IAC5B,QAAQ,CAAC,IAAI,IAAI,CAAA;IACjB,aAAa,CAAC,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAA;CACrC;AAED,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAA;IAChB,8EAA8E;IAC9E,aAAa,IAAI,eAAe,GAAG,IAAI,CAAA;IACvC;;;;OAIG;IACH,IAAI,CAAC,QAAQ,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC1F,0FAA0F;IAC1F,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,OAAO,CAAC,EAAE,mBAAmB,CAAA;IAC7B,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,kDAAkD;IAClD,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAC7D,4CAA4C;IAC5C,aAAa,CAAC,EAAE,gBAAgB,CAAC,eAAe,CAAC,CAAA;IACjD,GAAG,CAAC,EAAE,gBAAgB,CAAC,KAAK,CAAC,CAAA;IAC7B,QAAQ,CAAC,EAAE,gBAAgB,CAAC,UAAU,CAAC,CAAA;IACvC,UAAU,CAAC,EAAE,gBAAgB,CAAC,YAAY,CAAC,CAAA;IAC3C,OAAO,CAAC,EAAE,gBAAgB,CAAC,SAAS,CAAC,CAAA;CACtC;AAED,MAAM,WAAW,WAAW;IAC1B,6DAA6D;IAC7D,IAAI,IAAI,IAAI,CAAA;CACb;AAED,MAAM,WAAW,UAAU;IACzB,wFAAwF;IACxF,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,oBAAoB,CAAA;IAC5C,KAAK,IAAI,UAAU,CAAA;IACnB,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;IAC1D
|
|
1
|
+
{"version":3,"file":"live-client.d.ts","sourceRoot":"","sources":["../src/live-client.ts"],"names":[],"mappings":"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"}
|
package/dist/live-client.js
CHANGED
|
@@ -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([
|
|
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
|
}));
|
package/dist/live-client.js.map
CHANGED
|
@@ -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;
|
|
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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAaH,kEAAkE;AAClE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAI9D;AAED,2EAA2E;AAC3E,eAAO,MAAM,kBAAkB,QAAS,CAAA;AAExC;;;;GAIG;AACH,eAAO,MAAM,cAAc,OAAQ,CAAA;AAEnC,qFAAqF;AACrF,MAAM,WAAW,aAAa;IAC5B,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACnC,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,GAAG,IAAI,CAAA;IACrF,mBAAmB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,GAAG,IAAI,CAAA;CACzF;AAED,MAAM,WAAW,kBAAkB;IACjC,0CAA0C;IAC1C,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAED,MAAM,WAAW,WAAW;IAC1B;;;;;;;OAOG;IACH,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAA;IACvC,uCAAuC;IACvC,WAAW,IAAI,MAAM,CAAA;IACrB;;;;;OAKG;IACH,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1B,yDAAyD;IACzD,KAAK,IAAI,IAAI,CAAA;IACb,iDAAiD;IACjD,KAAK,IAAI,IAAI,CAAA;CACd;AAYD,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CA8C/E;AAED,8CAA8C;AAC9C,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC,gBAAgB,CAAC,IAAI,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;IACtE,mBAAmB,CAAC,IAAI,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;CAC1E;AAED,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;IAC3D,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;CAC/D;AAED,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,GAAG,IAAI,MAAM,CAAA;IACb,WAAW,CAAC,GAAG,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAA;IACjD,aAAa,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAA;CACrC;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,OAAQ,CAAA;AAErC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,MAAM,EAAE,WAAW,CAAA;IACnB,qDAAqD;IACrD,MAAM,CAAC,EAAE,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,CAAA;IACzC,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,cAAc,CAAA;CACvB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,SAAS,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,MAAM,IAAI,CAAA;CAC1F;AAWD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,uBAAuB,GAAG,gBAAgB,CAoEzF"}
|
package/dist/refresh.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Browser lifecycle two Estiva apps share: the one live relay socket a tab should hold, its credential read per connect,
|
|
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": {
|
package/src/folder-activity.ts
CHANGED
|
@@ -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
|
-
|
|
38
|
+
import type { SignedEvent } from '@estiva-app/protocol'
|
|
39
|
+
|
|
40
|
+
export type FolderListener = (event?: SignedEvent) => void
|
|
28
41
|
|
|
29
42
|
export interface FolderActivity {
|
|
30
|
-
/**
|
|
31
|
-
|
|
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,
|
|
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/live-client.ts
CHANGED
|
@@ -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
|
-
[
|
|
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
|
+
}
|