@estiva-app/platform 0.2.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 +20 -0
- package/README.md +31 -0
- 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/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/index.ts +20 -1
- package/src/refresh.ts +323 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
3
23
|
## 0.2.0
|
|
4
24
|
|
|
5
25
|
A Folder notification may carry the event that caused it (PER-8). Additive:
|
package/README.md
CHANGED
|
@@ -109,6 +109,37 @@ Pass `kinds` while you are at it: a kindless wide filter was refused outright on
|
|
|
109
109
|
that other build with `restricted: p-gated events require #p matching your
|
|
110
110
|
pubkey`.
|
|
111
111
|
|
|
112
|
+
### Refreshing, and waiting when the relay says so
|
|
113
|
+
|
|
114
|
+
Buzz meters `POST /query` at 300 a minute **per pubkey**, so every tab and app
|
|
115
|
+
signed in as one person shares one allowance. Build one budget and one
|
|
116
|
+
scheduler per tab:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { createRefreshScheduler, createRelayBudget } from '@estiva-app/platform'
|
|
120
|
+
|
|
121
|
+
// The transport writes it; the cadence reads it.
|
|
122
|
+
export const relayBudget = createRelayBudget({
|
|
123
|
+
// Optional: the app's other tabs learn a pause from this one.
|
|
124
|
+
channel: new BroadcastChannel('my-app-relay-budget'),
|
|
125
|
+
})
|
|
126
|
+
export const refreshScheduler = createRefreshScheduler({ document, window, budget: relayBudget })
|
|
127
|
+
|
|
128
|
+
// In the transport, on every answer:
|
|
129
|
+
if (status === 429 || isRateLimited(body)) relayBudget.noteRateLimited(body)
|
|
130
|
+
|
|
131
|
+
// In a hook: focus, becoming visible, and every 30 s while visible. A tab
|
|
132
|
+
// switch raises two events and costs one read; nothing runs while paused.
|
|
133
|
+
useEffect(() => refreshScheduler.subscribe(reload, { interval: !live }), [live])
|
|
134
|
+
|
|
135
|
+
// Before retrying a refused read: every waiter goes after the longest pause
|
|
136
|
+
// anybody learned, not its own shorter one.
|
|
137
|
+
await relayBudget.whenClear()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Nothing here gates a read. A read somebody asked for by clicking still goes out
|
|
141
|
+
at once; only the cadence backs off.
|
|
142
|
+
|
|
112
143
|
## What this deliberately does not know
|
|
113
144
|
|
|
114
145
|
Where anything is stored. Peek's version took a `LiveProjectionApi`, and
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* and the two relay clients, and deliberately contains no fold. This package
|
|
6
6
|
* sits above it and holds the things that are about *running in a tab* — the
|
|
7
7
|
* one socket a tab should have, the credential read per connect, the network
|
|
8
|
-
* coming back,
|
|
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"}
|
|
@@ -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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"}
|
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/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/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
|
+
}
|