@estiva-app/platform 0.3.1 → 0.5.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 +53 -0
- package/README.md +38 -1
- package/dist/event-snapshot.d.ts +179 -0
- package/dist/event-snapshot.d.ts.map +1 -0
- package/dist/event-snapshot.js +449 -0
- package/dist/event-snapshot.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/retrying-read.d.ts +62 -0
- package/dist/retrying-read.d.ts.map +1 -0
- package/dist/retrying-read.js +141 -0
- package/dist/retrying-read.js.map +1 -0
- package/package.json +2 -2
- package/src/event-snapshot.ts +583 -0
- package/src/index.ts +29 -0
- package/src/retrying-read.ts +194 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One read a tab holds on to, and reads again when it fails (FOL-5).
|
|
3
|
+
*
|
|
4
|
+
* Came from Peek's `useFolders` (FOL-42), which Ship needed word for word.
|
|
5
|
+
* Ship's read the Folder listing once and turned a failure into an empty list
|
|
6
|
+
* for the rest of the session: on production one refused read dropped every
|
|
7
|
+
* project out of its team until a reload. Peek had already paid for that
|
|
8
|
+
* lesson — a refused read left a project's channel unplaced and its team's dot
|
|
9
|
+
* dark — and fixed it with three rules, which are the whole of this object:
|
|
10
|
+
*
|
|
11
|
+
* 1. **A failure is read again**, on a backoff from `firstRetryMs` doubling to
|
|
12
|
+
* `maxRetryMs`, until one succeeds. A quota refusal waits the relay's own
|
|
13
|
+
* stated time instead ({@link retryHintMs}), never less than
|
|
14
|
+
* {@link MIN_BACKOFF_MS}. A failure the caller says will answer the same
|
|
15
|
+
* way (`retryable`) is not retried.
|
|
16
|
+
* 2. **A failure never replaces a good answer.** The last value stays; the
|
|
17
|
+
* failure is reported only while there is nothing else to show.
|
|
18
|
+
* 3. **Held past the component that asked.** Every page mounts its own
|
|
19
|
+
* sidebar; one of these per tab means a page change draws the answer the
|
|
20
|
+
* tab already has rather than a skeleton (Peek's SID-2).
|
|
21
|
+
*
|
|
22
|
+
* No React, no relay: the app hands in the read and wraps the object in its
|
|
23
|
+
* own hook — `useSyncExternalStore(read.subscribe, read.snapshot)` — so the
|
|
24
|
+
* package stays framework-free (ADR 0002 §10), as `createRefreshScheduler` is.
|
|
25
|
+
*/
|
|
26
|
+
import { MIN_BACKOFF_MS, isRateLimited, retryHintMs } from './refresh.js';
|
|
27
|
+
/** The first wait before reading a failed read again. Peek's `FOLDERS_RETRY_FIRST_MS`. */
|
|
28
|
+
export const RETRY_FIRST_MS = 5_000;
|
|
29
|
+
/** The longest wait between re-reads while a read keeps failing. */
|
|
30
|
+
export const RETRY_MAX_MS = 60_000;
|
|
31
|
+
const host = globalThis;
|
|
32
|
+
/** The wait before the next attempt, after `failures` consecutive retryable failures. */
|
|
33
|
+
export function retryDelayMs(failures, error, first = RETRY_FIRST_MS, max = RETRY_MAX_MS) {
|
|
34
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
35
|
+
if (isRateLimited(text)) {
|
|
36
|
+
const hint = retryHintMs(text);
|
|
37
|
+
if (hint !== undefined)
|
|
38
|
+
return Math.max(MIN_BACKOFF_MS, hint);
|
|
39
|
+
}
|
|
40
|
+
return Math.min(first * 2 ** Math.max(0, failures - 1), max);
|
|
41
|
+
}
|
|
42
|
+
export function createRetryingRead(options = {}) {
|
|
43
|
+
const retryable = options.retryable ?? (() => true);
|
|
44
|
+
const first = options.firstRetryMs ?? RETRY_FIRST_MS;
|
|
45
|
+
const max = options.maxRetryMs ?? RETRY_MAX_MS;
|
|
46
|
+
const freshMs = options.freshMs ?? Number.POSITIVE_INFINITY;
|
|
47
|
+
// Taken when the object is made, as Peek's listing cache did: freshness is
|
|
48
|
+
// wall-clock, and a test's fake `Date` must not make a held answer look
|
|
49
|
+
// fresh (or stale) to the next test. Pass `now` to control it.
|
|
50
|
+
const now = options.now ?? Date.now;
|
|
51
|
+
const timers = () => options.timers ?? host;
|
|
52
|
+
let snapshot = { retrying: false };
|
|
53
|
+
let answeredAt = 0;
|
|
54
|
+
let failures = 0;
|
|
55
|
+
let inflight;
|
|
56
|
+
let timer;
|
|
57
|
+
const listeners = new Set();
|
|
58
|
+
const set = (next) => {
|
|
59
|
+
snapshot = next;
|
|
60
|
+
for (const listener of [...listeners])
|
|
61
|
+
listener();
|
|
62
|
+
};
|
|
63
|
+
const cancel = () => {
|
|
64
|
+
if (timer === undefined)
|
|
65
|
+
return;
|
|
66
|
+
timers().clearTimeout(timer);
|
|
67
|
+
timer = undefined;
|
|
68
|
+
};
|
|
69
|
+
/* Bumped by `clear`, so a read in flight across it cannot write the old
|
|
70
|
+
answer back or schedule a retry for it. */
|
|
71
|
+
let generation = 0;
|
|
72
|
+
const run = (load) => {
|
|
73
|
+
if (inflight)
|
|
74
|
+
return inflight;
|
|
75
|
+
cancel();
|
|
76
|
+
const started = generation;
|
|
77
|
+
inflight = (async () => {
|
|
78
|
+
try {
|
|
79
|
+
const value = await load();
|
|
80
|
+
if (started !== generation)
|
|
81
|
+
return;
|
|
82
|
+
failures = 0;
|
|
83
|
+
answeredAt = now();
|
|
84
|
+
set({ value, retrying: false });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (started !== generation)
|
|
88
|
+
return;
|
|
89
|
+
const again = retryable(error);
|
|
90
|
+
if (again) {
|
|
91
|
+
failures += 1;
|
|
92
|
+
timer = timers().setTimeout(() => {
|
|
93
|
+
timer = undefined;
|
|
94
|
+
void run(load);
|
|
95
|
+
}, retryDelayMs(failures, error, first, max));
|
|
96
|
+
}
|
|
97
|
+
// A good answer outlives any failure after it.
|
|
98
|
+
set(snapshot.value !== undefined ? { value: snapshot.value, retrying: again } : { error, retrying: again });
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (started === generation)
|
|
102
|
+
inflight = undefined;
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
return inflight;
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
snapshot: () => snapshot,
|
|
109
|
+
subscribe(listener) {
|
|
110
|
+
listeners.add(listener);
|
|
111
|
+
return () => {
|
|
112
|
+
listeners.delete(listener);
|
|
113
|
+
// Nobody is looking: a retry would spend the relay's budget on an
|
|
114
|
+
// answer no one draws. The snapshot still says a retry is due, and
|
|
115
|
+
// the next `ensure` makes it.
|
|
116
|
+
if (listeners.size === 0)
|
|
117
|
+
cancel();
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
ensure(load) {
|
|
121
|
+
if (inflight)
|
|
122
|
+
return inflight;
|
|
123
|
+
if (timer !== undefined)
|
|
124
|
+
return Promise.resolve();
|
|
125
|
+
if (snapshot.value !== undefined && now() - answeredAt < freshMs)
|
|
126
|
+
return Promise.resolve();
|
|
127
|
+
return run(load);
|
|
128
|
+
},
|
|
129
|
+
reload: run,
|
|
130
|
+
dispose: cancel,
|
|
131
|
+
clear() {
|
|
132
|
+
generation += 1;
|
|
133
|
+
cancel();
|
|
134
|
+
inflight = undefined;
|
|
135
|
+
failures = 0;
|
|
136
|
+
answeredAt = 0;
|
|
137
|
+
set({ retrying: false });
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=retrying-read.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retrying-read.js","sourceRoot":"","sources":["../src/retrying-read.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, the network coming back,
|
|
3
|
+
"version": "0.5.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, when a tab re-reads, and the copy on the device a reload draws from.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|