@gusnips/react 0.9.12 → 0.9.14
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/README.md +27 -2
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +2 -1
- package/dist/api-client.js.map +1 -1
- package/dist/api-error.d.ts +1 -43
- package/dist/api-error.d.ts.map +1 -1
- package/dist/api-error.js +1 -71
- package/dist/api-error.js.map +1 -1
- package/dist/copy.d.ts +23 -0
- package/dist/copy.d.ts.map +1 -0
- package/dist/copy.js +46 -0
- package/dist/copy.js.map +1 -0
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +2 -1
- package/dist/describe-error.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/query.d.ts +29 -1
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +21 -60
- package/dist/query.js.map +1 -1
- package/package.json +2 -2
- package/src/api-client.test.ts +4 -3
- package/src/api-client.ts +2 -1
- package/src/api-error.ts +1 -71
- package/src/copy.test.ts +29 -0
- package/src/copy.ts +62 -0
- package/src/describe-error.ts +2 -1
- package/src/index.ts +3 -1
- package/src/query.test.ts +11 -0
- package/src/query.ts +24 -34
- package/src/api-error.test.ts +0 -119
package/README.md
CHANGED
|
@@ -163,8 +163,10 @@ new QueryClient({
|
|
|
163
163
|
});
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
-
Retries 408, a transient 429, 5xx, and no-response-at-all. Nothing else — every other 4xx is
|
|
167
|
-
answer, and a second attempt just says it again.
|
|
166
|
+
Retries 408, 425, a transient 429, 5xx, and no-response-at-all. Nothing else — every other 4xx is
|
|
167
|
+
an answer, and a second attempt just says it again. The rule itself is
|
|
168
|
+
[`@gusnips/http/retry`](../http#should-this-request-be-tried-again), so an SDK with no React in it
|
|
169
|
+
can retry the same way.
|
|
168
170
|
|
|
169
171
|
`durableLimitCodes` is the part a status code cannot tell you. A spent monthly quota and a burst
|
|
170
172
|
limit both arrive as 429, but one clears by waiting and the other clears by buying. Retrying the
|
|
@@ -417,6 +419,27 @@ In jsdom tests, mock `window.matchMedia`, which jsdom does not have, and run
|
|
|
417
419
|
`delete window.__frontkitTheme` between tests. There is one controller per page, and a jsdom
|
|
418
420
|
window lasts the whole file.
|
|
419
421
|
|
|
422
|
+
## A copy button that says when it failed
|
|
423
|
+
|
|
424
|
+
```tsx
|
|
425
|
+
import { useCopy } from "@gusnips/react";
|
|
426
|
+
|
|
427
|
+
const { state, copy } = useCopy();
|
|
428
|
+
|
|
429
|
+
<button onClick={() => void copy(apiKey)} aria-live="polite">
|
|
430
|
+
{state === "copied" ? "Copied" : state === "failed" ? "Select it and copy it yourself" : "Copy"}
|
|
431
|
+
</button>;
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Of eight copy buttons we read, five failed without a word, so the reader pasted whatever was
|
|
435
|
+
already on the clipboard. Two more threw on a page served without https, where there is no
|
|
436
|
+
clipboard API. `useCopy` shows `"copied"` for 2 seconds or `"failed"` for 6, then goes back to
|
|
437
|
+
`"idle"`, and a second copy restarts the clock. `copy` resolves `false` instead of throwing, so a
|
|
438
|
+
toast needs no `catch`. Outside a component, `copyText(text)` does the write on its own.
|
|
439
|
+
|
|
440
|
+
The words are yours, and so is the live region: a label that changes needs `aria-live` to be
|
|
441
|
+
heard.
|
|
442
|
+
|
|
420
443
|
## Also here
|
|
421
444
|
|
|
422
445
|
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
|
@@ -459,6 +482,8 @@ writes you no longer own.
|
|
|
459
482
|
The main entry works in React Native. It imports `react` and nothing else you would have to go
|
|
460
483
|
find — no `react-dom`, no router, no store — so the fetch client, the retry rule and the SSE
|
|
461
484
|
parser all come along, and the four things that need a browser stay behind the subpaths above.
|
|
485
|
+
`useCopy` imports fine there but has no `navigator.clipboard` to write to, so every copy answers
|
|
486
|
+
`"failed"`: use the platform's clipboard on a phone.
|
|
462
487
|
|
|
463
488
|
## The Base UI wrappers
|
|
464
489
|
|
package/dist/api-client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
|
package/dist/api-client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isApiError } from "@gusnips/http";
|
|
2
|
-
import {
|
|
2
|
+
import { parseRetryAfter } from "@gusnips/http/retry";
|
|
3
|
+
import { ApiError } from "./api-error.js";
|
|
3
4
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4
5
|
/**
|
|
5
6
|
* One signal that fires on the caller's abort or on the deadline, whichever comes first.
|
package/dist/api-client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAmB,MAAM,eAAe,CAAC;AAC5D,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
|
package/dist/api-error.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export declare class ApiError extends Error {
|
|
|
25
25
|
* `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader —
|
|
26
26
|
* which looked in `details.retryAfterSecs`, the first donor's convention — found nothing, and
|
|
27
27
|
* both the copy and the retry rule were flying blind on the one refusal that states its own
|
|
28
|
-
* expiry. Two conventions, same fact;
|
|
28
|
+
* expiry. Two conventions, same fact; `retryAfterSecs` from `@gusnips/http/retry` reads either.
|
|
29
29
|
*/
|
|
30
30
|
readonly retryAfterSecs: number | undefined;
|
|
31
31
|
/**
|
|
@@ -45,48 +45,6 @@ export declare class ApiError extends Error {
|
|
|
45
45
|
retryAfterSecs?: number;
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Seconds until a refusal clears, from whichever place the API put them.
|
|
50
|
-
*
|
|
51
|
-
* The header first, because it is the standard one and it is what a limiter emits without being
|
|
52
|
-
* asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
|
|
53
|
-
* caller asking "how long" should not have to know which server it is talking to.
|
|
54
|
-
*
|
|
55
|
-
* It deliberately does not answer whether waiting helps at all. `null` here means "no countdown
|
|
56
|
-
* to show", which is the right answer both for a refusal that said nothing and for one that said
|
|
57
|
-
* waiting will never fix it — and the adopters' countdown UIs read it for exactly that. Telling
|
|
58
|
-
* those two apart is {@link waitingNeverHelps}.
|
|
59
|
-
*/
|
|
60
|
-
export declare function retryAfterSecs(error: ApiError): number | null;
|
|
61
|
-
/**
|
|
62
|
-
* Did the answer say, in so many words, that waiting will never fix this?
|
|
63
|
-
*
|
|
64
|
-
* The server half of this fleet sends `details.retryAfterSecs: null` on a refusal no amount of
|
|
65
|
-
* time clears — a cap that frees only when somebody deletes something, a concurrency slot that
|
|
66
|
-
* frees when another job ends. An explicit `null` is a claim somebody has to read; an omitted key
|
|
67
|
-
* is invisible. That is the whole reason the two must not collapse into one answer: this is the
|
|
68
|
-
* server naming what `durableLimitCodes` otherwise makes every app name by hand, one code at a
|
|
69
|
-
* time, and keep in step with a backend it does not own.
|
|
70
|
-
*
|
|
71
|
-
* Present-and-`null` is the entire test. An absent key reads as `undefined`, so `=== null`
|
|
72
|
-
* already separates the claim from the silence and an `in` check would only widen it to every
|
|
73
|
-
* stated wait. Body-only by design: `Retry-After` has no spelling for "never".
|
|
74
|
-
*
|
|
75
|
-
* Not exported from the package, and that is the point: it is not "is this refusal durable?". It
|
|
76
|
-
* says nothing about a durable CODE or a wait too long to hold, so an error surface branching on
|
|
77
|
-
* it would answer differently from the retry rule for every limit the app names itself. The one
|
|
78
|
-
* question worth asking out loud is `shouldRetry`, and `describeError` already derives the
|
|
79
|
-
* control it offers from exactly that — invariant 8, one rule and two consumers.
|
|
80
|
-
*/
|
|
81
|
-
export declare function waitingNeverHelps(error: ApiError): boolean;
|
|
82
|
-
/**
|
|
83
|
-
* `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
|
|
84
|
-
*
|
|
85
|
-
* Both forms are real — RFC 9110 allows either and different servers pick differently — and the
|
|
86
|
-
* date one is the half that gets skipped, which turns a stated wait into no wait at all. Clamped
|
|
87
|
-
* at zero: a date already in the past means "now", not a negative delay.
|
|
88
|
-
*/
|
|
89
|
-
export declare function parseRetryAfter(value: string | null | undefined): number | undefined;
|
|
90
48
|
/**
|
|
91
49
|
* An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
|
|
92
50
|
* component waiting on it.
|
package/dist/api-error.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-error.d.ts","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,SAAS,CAAC;IAC7D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC;;;;;;;;;OASG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IAE5C;;;;;;;;OAQG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;gBAGzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,IAAI,EAClC,IAAI,GAAE;QACJ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,cAAc,CAAC,EAAE,MAAM,CAAC;KACpB;CAaT;AAED
|
|
1
|
+
{"version":3,"file":"api-error.d.ts","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,SAAS,CAAC;IAC7D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC;;;;;;;;;OASG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IAE5C;;;;;;;;OAQG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;gBAGzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,IAAI,EAClC,IAAI,GAAE;QACJ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,cAAc,CAAC,EAAE,MAAM,CAAC;KACpB;CAaT;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEpD"}
|
package/dist/api-error.js
CHANGED
|
@@ -24,7 +24,7 @@ export class ApiError extends Error {
|
|
|
24
24
|
* `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader —
|
|
25
25
|
* which looked in `details.retryAfterSecs`, the first donor's convention — found nothing, and
|
|
26
26
|
* both the copy and the retry rule were flying blind on the one refusal that states its own
|
|
27
|
-
* expiry. Two conventions, same fact;
|
|
27
|
+
* expiry. Two conventions, same fact; `retryAfterSecs` from `@gusnips/http/retry` reads either.
|
|
28
28
|
*/
|
|
29
29
|
retryAfterSecs;
|
|
30
30
|
/**
|
|
@@ -50,76 +50,6 @@ export class ApiError extends Error {
|
|
|
50
50
|
this.expected = opts.expected ?? false;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Seconds until a refusal clears, from whichever place the API put them.
|
|
55
|
-
*
|
|
56
|
-
* The header first, because it is the standard one and it is what a limiter emits without being
|
|
57
|
-
* asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
|
|
58
|
-
* caller asking "how long" should not have to know which server it is talking to.
|
|
59
|
-
*
|
|
60
|
-
* It deliberately does not answer whether waiting helps at all. `null` here means "no countdown
|
|
61
|
-
* to show", which is the right answer both for a refusal that said nothing and for one that said
|
|
62
|
-
* waiting will never fix it — and the adopters' countdown UIs read it for exactly that. Telling
|
|
63
|
-
* those two apart is {@link waitingNeverHelps}.
|
|
64
|
-
*/
|
|
65
|
-
export function retryAfterSecs(error) {
|
|
66
|
-
if (error.retryAfterSecs !== undefined)
|
|
67
|
-
return error.retryAfterSecs;
|
|
68
|
-
const details = error.details;
|
|
69
|
-
if (typeof details !== "object" || details === null)
|
|
70
|
-
return null;
|
|
71
|
-
const value = details.retryAfterSecs;
|
|
72
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Did the answer say, in so many words, that waiting will never fix this?
|
|
76
|
-
*
|
|
77
|
-
* The server half of this fleet sends `details.retryAfterSecs: null` on a refusal no amount of
|
|
78
|
-
* time clears — a cap that frees only when somebody deletes something, a concurrency slot that
|
|
79
|
-
* frees when another job ends. An explicit `null` is a claim somebody has to read; an omitted key
|
|
80
|
-
* is invisible. That is the whole reason the two must not collapse into one answer: this is the
|
|
81
|
-
* server naming what `durableLimitCodes` otherwise makes every app name by hand, one code at a
|
|
82
|
-
* time, and keep in step with a backend it does not own.
|
|
83
|
-
*
|
|
84
|
-
* Present-and-`null` is the entire test. An absent key reads as `undefined`, so `=== null`
|
|
85
|
-
* already separates the claim from the silence and an `in` check would only widen it to every
|
|
86
|
-
* stated wait. Body-only by design: `Retry-After` has no spelling for "never".
|
|
87
|
-
*
|
|
88
|
-
* Not exported from the package, and that is the point: it is not "is this refusal durable?". It
|
|
89
|
-
* says nothing about a durable CODE or a wait too long to hold, so an error surface branching on
|
|
90
|
-
* it would answer differently from the retry rule for every limit the app names itself. The one
|
|
91
|
-
* question worth asking out loud is `shouldRetry`, and `describeError` already derives the
|
|
92
|
-
* control it offers from exactly that — invariant 8, one rule and two consumers.
|
|
93
|
-
*/
|
|
94
|
-
export function waitingNeverHelps(error) {
|
|
95
|
-
const details = error.details;
|
|
96
|
-
if (typeof details !== "object" || details === null)
|
|
97
|
-
return false;
|
|
98
|
-
return details.retryAfterSecs === null;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
|
|
102
|
-
*
|
|
103
|
-
* Both forms are real — RFC 9110 allows either and different servers pick differently — and the
|
|
104
|
-
* date one is the half that gets skipped, which turns a stated wait into no wait at all. Clamped
|
|
105
|
-
* at zero: a date already in the past means "now", not a negative delay.
|
|
106
|
-
*/
|
|
107
|
-
export function parseRetryAfter(value) {
|
|
108
|
-
const raw = value?.trim();
|
|
109
|
-
if (!raw)
|
|
110
|
-
return undefined;
|
|
111
|
-
// A bare integer is the delay-seconds form, malformed included: `Date.parse` accepts some of
|
|
112
|
-
// them as years, so falling through would read "-5" as a date long past and answer 0 — a
|
|
113
|
-
// wait invented out of a header that never stated one.
|
|
114
|
-
if (/^[+-]?\d+$/.test(raw)) {
|
|
115
|
-
const secs = Number(raw);
|
|
116
|
-
return Number.isSafeInteger(secs) && secs >= 0 ? secs : undefined;
|
|
117
|
-
}
|
|
118
|
-
const at = Date.parse(raw);
|
|
119
|
-
if (Number.isNaN(at))
|
|
120
|
-
return undefined;
|
|
121
|
-
return Math.max(0, Math.round((at - Date.now()) / 1000));
|
|
122
|
-
}
|
|
123
53
|
/**
|
|
124
54
|
* An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
|
|
125
55
|
* component waiting on it.
|
package/dist/api-error.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-error.js","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,OAAO,QAAS,SAAQ,KAAK;IACxB,MAAM,CAAS;IACf,IAAI,CAAqB;IACzB,UAAU,CAAqB;IAC/B,MAAM,CAA8C;IACpD,OAAO,CAAU;IACjB,SAAS,CAAqB;IACvC;;;;;;;;;OASG;IACM,cAAc,CAAqB;IAE5C;;;;;;;;OAQG;IACM,QAAQ,CAAU;IAE3B,YACE,MAAc,EACd,IAAkC,EAClC,OAKI,EAAE;QAEN,KAAK,CAAC,IAAI,CAAC,OAAO,IAAI,IAAI,EAAE,OAAO,IAAI,mBAAmB,MAAM,GAAG,CAAC,CAAC;QACrE,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,EAAE,IAAI,CAAC;QACvB,IAAI,CAAC,UAAU,GAAG,IAAI,EAAE,UAAU,CAAC;QACnC,IAAI,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC;QAC3B,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,OAAO,CAAC;QAC7B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAChC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,cAAc,CAAC;QAC1C,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,KAAK,CAAC;IACzC,CAAC;CACF;AAED
|
|
1
|
+
{"version":3,"file":"api-error.js","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,OAAO,QAAS,SAAQ,KAAK;IACxB,MAAM,CAAS;IACf,IAAI,CAAqB;IACzB,UAAU,CAAqB;IAC/B,MAAM,CAA8C;IACpD,OAAO,CAAU;IACjB,SAAS,CAAqB;IACvC;;;;;;;;;OASG;IACM,cAAc,CAAqB;IAE5C;;;;;;;;OAQG;IACM,QAAQ,CAAU;IAE3B,YACE,MAAc,EACd,IAAkC,EAClC,OAKI,EAAE;QAEN,KAAK,CAAC,IAAI,CAAC,OAAO,IAAI,IAAI,EAAE,OAAO,IAAI,mBAAmB,MAAM,GAAG,CAAC,CAAC;QACrE,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,EAAE,IAAI,CAAC;QACvB,IAAI,CAAC,UAAU,GAAG,IAAI,EAAE,UAAU,CAAC;QACnC,IAAI,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC;QAC3B,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,OAAO,CAAC;QAC7B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAChC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,cAAc,CAAC;QAC1C,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,KAAK,CAAC;IACzC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,OAAO,KAAK,YAAY,YAAY,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY,CAAC;AACtE,CAAC"}
|
package/dist/copy.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Write `text` to the clipboard. `false` when the browser refused or has no clipboard API. */
|
|
2
|
+
export declare function copyText(text: string): Promise<boolean>;
|
|
3
|
+
export type CopyState = "idle" | "copied" | "failed";
|
|
4
|
+
export interface CopyOptions {
|
|
5
|
+
/** How long `"copied"` shows. Default 2000 ms. */
|
|
6
|
+
copiedMs?: number;
|
|
7
|
+
/** How long `"failed"` shows. Default 6000 ms: a failure is a sentence the reader has to act on. */
|
|
8
|
+
failedMs?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A copy with an outcome the reader can see. What it says stays yours — "Copied", or "Select it
|
|
12
|
+
* and copy it yourself", in a label or a toast — and so does announcing it: a label that changes
|
|
13
|
+
* needs `aria-live="polite"` to be heard.
|
|
14
|
+
*
|
|
15
|
+
* The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
|
|
16
|
+
* look like it did not take.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useCopy({ copiedMs, failedMs }?: CopyOptions): {
|
|
19
|
+
state: CopyState;
|
|
20
|
+
/** Resolves `false` instead of throwing, so a caller can toast without a `catch`. */
|
|
21
|
+
copy: (text: string) => Promise<boolean>;
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=copy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"copy.d.ts","sourceRoot":"","sources":["../src/copy.ts"],"names":[],"mappings":"AAWA,+FAA+F;AAC/F,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO7D;AAED,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAErD,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oGAAoG;IACpG,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,EAAE,QAAe,EAAE,QAAe,EAAE,GAAE,WAAgB,GAAG;IAC/E,KAAK,EAAE,SAAS,CAAC;IACjB,qFAAqF;IACrF,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1C,CAmBA"}
|
package/dist/copy.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
// Eight products wrote a copy button, and five of them failed silently, one under a comment
|
|
3
|
+
// saying it must not. Silent is worse than it looks: the reader pastes whatever was already on
|
|
4
|
+
// the clipboard, and for a key shown once that is pasting something else over the only copy.
|
|
5
|
+
// Two more called `writeText` outside a `try`, and on a page not served over https there is no
|
|
6
|
+
// `navigator.clipboard` at all, so the click threw a TypeError instead of rejecting.
|
|
7
|
+
//
|
|
8
|
+
// No `execCommand` fallback, and none of the eight had one: it is deprecated, the async API is
|
|
9
|
+
// there in every browser over https, and a copy that says it failed beats a shim that pretends.
|
|
10
|
+
/** Write `text` to the clipboard. `false` when the browser refused or has no clipboard API. */
|
|
11
|
+
export async function copyText(text) {
|
|
12
|
+
try {
|
|
13
|
+
await navigator.clipboard.writeText(text);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A copy with an outcome the reader can see. What it says stays yours — "Copied", or "Select it
|
|
22
|
+
* and copy it yourself", in a label or a toast — and so does announcing it: a label that changes
|
|
23
|
+
* needs `aria-live="polite"` to be heard.
|
|
24
|
+
*
|
|
25
|
+
* The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
|
|
26
|
+
* look like it did not take.
|
|
27
|
+
*/
|
|
28
|
+
export function useCopy({ copiedMs = 2000, failedMs = 6000 } = {}) {
|
|
29
|
+
// A new object on every copy, so copying again restarts the clock even when the state is the
|
|
30
|
+
// same: two copies a second apart read "Copied" for two seconds after the second one.
|
|
31
|
+
const [outcome, setOutcome] = useState({ state: "idle" });
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (outcome.state === "idle")
|
|
34
|
+
return;
|
|
35
|
+
const ms = outcome.state === "copied" ? copiedMs : failedMs;
|
|
36
|
+
const timer = setTimeout(() => setOutcome({ state: "idle" }), ms);
|
|
37
|
+
return () => clearTimeout(timer);
|
|
38
|
+
}, [outcome, copiedMs, failedMs]);
|
|
39
|
+
const copy = useCallback(async (text) => {
|
|
40
|
+
const copied = await copyText(text);
|
|
41
|
+
setOutcome({ state: copied ? "copied" : "failed" });
|
|
42
|
+
return copied;
|
|
43
|
+
}, []);
|
|
44
|
+
return { state: outcome.state, copy };
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=copy.js.map
|
package/dist/copy.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"copy.js","sourceRoot":"","sources":["../src/copy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEzD,4FAA4F;AAC5F,+FAA+F;AAC/F,6FAA6F;AAC7F,+FAA+F;AAC/F,qFAAqF;AACrF,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAEhG,+FAA+F;AAC/F,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY;IACzC,IAAI,CAAC;QACH,MAAM,SAAS,CAAC,SAAS,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QAC1C,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAWD;;;;;;;GAOG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,QAAQ,GAAG,IAAI,EAAE,QAAQ,GAAG,IAAI,KAAkB,EAAE;IAK5E,6FAA6F;IAC7F,sFAAsF;IACtF,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAuB,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;IAEhF,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO,CAAC,KAAK,KAAK,MAAM;YAAE,OAAO;QACrC,MAAM,EAAE,GAAG,OAAO,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;QAC5D,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;QAClE,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IACnC,CAAC,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC;IAElC,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE;QAC9C,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpC,UAAU,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;QACpD,OAAO,MAAM,CAAC;IAChB,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC;AACxC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/dist/describe-error.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { cn } from "./cn.ts";
|
|
2
|
+
export { copyText, useCopy, type CopyOptions, type CopyState } from "./copy.ts";
|
|
2
3
|
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
3
|
-
export { ApiError, isAbortError
|
|
4
|
+
export { ApiError, isAbortError } from "./api-error.ts";
|
|
5
|
+
export { retryAfterSecs } from "@gusnips/http/retry";
|
|
4
6
|
export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshResult, type RequestOptions, type SessionAdapter, } from "./api-client.ts";
|
|
5
7
|
export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
|
|
6
8
|
export { queryDefaults, queryView, retryDelayMs, shouldRetry, type QueryDefaultsOptions, type QueryView, } from "./query.ts";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
// Check that claim against `dist/`, never against this file: a type-only re-export looks
|
|
31
31
|
// identical in source.
|
|
32
32
|
export { cn } from "./cn.js";
|
|
33
|
+
export { copyText, useCopy } from "./copy.js";
|
|
33
34
|
export { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
|
|
34
|
-
export { ApiError, isAbortError
|
|
35
|
+
export { ApiError, isAbortError } from "./api-error.js";
|
|
36
|
+
export { retryAfterSecs } from "@gusnips/http/retry";
|
|
35
37
|
export { createApiClient, } from "./api-client.js";
|
|
36
38
|
export { createSseParser, readSseStream } from "./sse.js";
|
|
37
39
|
export { queryDefaults, queryView, retryDelayMs, shouldRetry, } from "./query.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAQb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
|
package/dist/query.d.ts
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
import type { DefaultOptions } from "@tanstack/react-query";
|
|
2
|
+
/**
|
|
3
|
+
* react-query defaults — the merge of three repos that each got part of this right.
|
|
4
|
+
*
|
|
5
|
+
* The default is three blind retries, which is wrong for an API that answers refusals
|
|
6
|
+
* precisely: a 402, a 403 or a 451 says exactly the same thing three times, and a 429 we hammer
|
|
7
|
+
* is a 429 we deserve. All three donors knew that much. What each knew alone:
|
|
8
|
+
*
|
|
9
|
+
* - one allowed **408** through, the only 4xx that a second attempt genuinely fixes;
|
|
10
|
+
* - one refused to retry a 429 whose CODE says the limit is durable — a monthly quota or a
|
|
11
|
+
* spent balance clears by buying, not by waiting, so retrying burns another request against
|
|
12
|
+
* the limiter and cannot succeed;
|
|
13
|
+
* - one set **`refetchOnReconnect`**, which none of the others had, and which is the single
|
|
14
|
+
* most useful refetch there is: coming back from a tunnel is exactly when the screen is stale.
|
|
15
|
+
*
|
|
16
|
+
* The second migration added the fourth rule, which no donor had: **when a 429 says how long,
|
|
17
|
+
* believe it.** Its limiter answers `Retry-After: 60` on a per-minute window, and the default
|
|
18
|
+
* backoff retries at 1s and 2s — two more requests that cannot succeed, both charged against the
|
|
19
|
+
* same limiter, and three seconds added before the screen says anything. So a stated wait is
|
|
20
|
+
* waited out when it is short, and is an ANSWER when it is not.
|
|
21
|
+
*
|
|
22
|
+
* The fifth rule came from the server half of this fleet rather than from a donor: a refusal can
|
|
23
|
+
* state that waiting will never fix it at all, by sending `details.retryAfterSecs: null`. Read as
|
|
24
|
+
* "did not say how long" — which is what every version before this one did — that 429 is retried
|
|
25
|
+
* against a limit no amount of time moves.
|
|
26
|
+
*
|
|
27
|
+
* This is invariant 8. The rule itself lives in `@gusnips/http/retry`, so an SDK with no React
|
|
28
|
+
* in it follows the same one; what is here is the binding to react-query.
|
|
29
|
+
*/
|
|
2
30
|
export interface QueryDefaultsOptions {
|
|
3
31
|
/**
|
|
4
32
|
* Error codes that mean "this limit does not clear by waiting" — a spent monthly quota, a
|
|
@@ -34,7 +62,7 @@ export interface QueryDefaultsOptions {
|
|
|
34
62
|
* Should this failure be retried?
|
|
35
63
|
*
|
|
36
64
|
* Exported on its own because an app with its own QueryClient config still wants this rule, and
|
|
37
|
-
* because
|
|
65
|
+
* because the describer derives the control it offers from it.
|
|
38
66
|
*/
|
|
39
67
|
export declare function shouldRetry(error: unknown, durableLimitCodes?: readonly string[], maxRetryWaitSecs?: number): boolean;
|
|
40
68
|
/**
|
package/dist/query.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAG5D;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,iBAAiB,GAAE,SAAS,MAAM,EAAO,EACzC,gBAAgB,CAAC,EAAE,MAAM,GACxB,OAAO,CAOT;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAEzE;AAYD,wBAAgB,aAAa,CAAC,EAC5B,iBAAsB,EACtB,UAAc,EACd,gBAAgB,EAChB,SAAyB,EACzB,MAAuB,EACvB,oBAA4B,GAC7B,GAAE,oBAAyB,GAAG,cAAc,CAe5C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,IAChC;IAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAC7B;IAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,GAAG,IAAI,CAAA;CAAE,CAAC;AAEnF,wBAAgB,SAAS,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE;IACrC,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,SAAS,CAAC;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC;CAC3B,GAAG,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAMlB"}
|
package/dist/query.js
CHANGED
|
@@ -1,62 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
* react-query defaults — the merge of three repos that each got part of this right.
|
|
4
|
-
*
|
|
5
|
-
* The default is three blind retries, which is wrong for an API that answers refusals
|
|
6
|
-
* precisely: a 402, a 403 or a 451 says exactly the same thing three times, and a 429 we hammer
|
|
7
|
-
* is a 429 we deserve. All three donors knew that much. What each knew alone:
|
|
8
|
-
*
|
|
9
|
-
* - one allowed **408** through, the only 4xx that a second attempt genuinely fixes;
|
|
10
|
-
* - one refused to retry a 429 whose CODE says the limit is durable — a monthly quota or a
|
|
11
|
-
* spent balance clears by buying, not by waiting, so retrying burns another request against
|
|
12
|
-
* the limiter and cannot succeed;
|
|
13
|
-
* - one set **`refetchOnReconnect`**, which none of the others had, and which is the single
|
|
14
|
-
* most useful refetch there is: coming back from a tunnel is exactly when the screen is stale.
|
|
15
|
-
*
|
|
16
|
-
* The second migration added the fourth rule, which no donor had: **when a 429 says how long,
|
|
17
|
-
* believe it.** Its limiter answers `Retry-After: 60` on a per-minute window, and the default
|
|
18
|
-
* backoff retries at 1s and 2s — two more requests that cannot succeed, both charged against the
|
|
19
|
-
* same limiter, and three seconds added before the screen says anything. So a stated wait is
|
|
20
|
-
* waited out when it is short, and is an ANSWER when it is not.
|
|
21
|
-
*
|
|
22
|
-
* The fifth rule came from the server half of this fleet rather than from a donor: a refusal can
|
|
23
|
-
* state that waiting will never fix it at all, by sending `details.retryAfterSecs: null`. Read as
|
|
24
|
-
* "did not say how long" — which is what every version before this one did — that 429 is retried
|
|
25
|
-
* against a limit no amount of time moves.
|
|
26
|
-
*
|
|
27
|
-
* This is invariant 8.
|
|
28
|
-
*/
|
|
29
|
-
/** 4xx statuses a second attempt can fix. Everything else in the 4xx family is an answer. */
|
|
30
|
-
const RETRYABLE_CLIENT_STATUS = new Set([408, 429]);
|
|
31
|
-
const DEFAULT_MAX_RETRY_WAIT_SECS = 10;
|
|
1
|
+
import { retryDelayMs as delayFor, shouldRetry as retryRule } from "@gusnips/http/retry";
|
|
2
|
+
import { ApiError } from "./api-error.js";
|
|
32
3
|
/**
|
|
33
4
|
* Should this failure be retried?
|
|
34
5
|
*
|
|
35
6
|
* Exported on its own because an app with its own QueryClient config still wants this rule, and
|
|
36
|
-
* because
|
|
7
|
+
* because the describer derives the control it offers from it.
|
|
37
8
|
*/
|
|
38
|
-
export function shouldRetry(error, durableLimitCodes = [], maxRetryWaitSecs
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// The same judgement, made by the server instead of by a list the app maintains: an explicit
|
|
46
|
-
// `details.retryAfterSecs: null` means waiting will never fix this. Checked before the stated
|
|
47
|
-
// wait because the two can contradict each other, and the `null` is written by whoever raised
|
|
48
|
-
// this particular refusal where a number can come from a limiter that attaches one to every
|
|
49
|
-
// 429 it emits.
|
|
50
|
-
if (waitingNeverHelps(error))
|
|
51
|
-
return false;
|
|
52
|
-
// It told us when it clears. A wait we are not willing to hold is a refusal, not a hiccup —
|
|
53
|
-
// and it is the same judgement `durableLimitCodes` makes, except the server did the naming.
|
|
54
|
-
const wait = retryAfterSecs(error);
|
|
55
|
-
if (wait !== null && wait > maxRetryWaitSecs)
|
|
56
|
-
return false;
|
|
57
|
-
if (error.status >= 500)
|
|
58
|
-
return true;
|
|
59
|
-
return RETRYABLE_CLIENT_STATUS.has(error.status);
|
|
9
|
+
export function shouldRetry(error, durableLimitCodes = [], maxRetryWaitSecs) {
|
|
10
|
+
// Every caller here is a query, or a person pressing "try again", so the request may run twice.
|
|
11
|
+
return retryRule(answerOf(error), {
|
|
12
|
+
repeatable: true,
|
|
13
|
+
durableCodes: durableLimitCodes,
|
|
14
|
+
maxWaitSecs: maxRetryWaitSecs,
|
|
15
|
+
});
|
|
60
16
|
}
|
|
61
17
|
/**
|
|
62
18
|
* How long before the next attempt: the server's stated wait when there is one, react-query's
|
|
@@ -64,13 +20,18 @@ export function shouldRetry(error, durableLimitCodes = [], maxRetryWaitSecs = DE
|
|
|
64
20
|
* server saying "immediately", which for a client that just got refused is still too soon.
|
|
65
21
|
*/
|
|
66
22
|
export function retryDelayMs(attemptIndex, error) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
23
|
+
return delayFor(attemptIndex, answerOf(error));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Only this client's `ApiError` is an answer. The shared rule reads any error by its fields, so a
|
|
27
|
+
* vendor's error with a `status` of its own would count as one — and the describer reads anything
|
|
28
|
+
* that is not an `ApiError` as a request that never landed, offering "try again" where the retry
|
|
29
|
+
* rule would refuse. Handing the rule `null` keeps the two reading one failure the same way.
|
|
30
|
+
*/
|
|
31
|
+
function answerOf(error) {
|
|
32
|
+
return error instanceof ApiError ? error : null;
|
|
72
33
|
}
|
|
73
|
-
export function queryDefaults({ durableLimitCodes = [], maxRetries = 2, maxRetryWaitSecs
|
|
34
|
+
export function queryDefaults({ durableLimitCodes = [], maxRetries = 2, maxRetryWaitSecs, staleTime = 2 * 60 * 1000, gcTime = 10 * 60 * 1000, refetchOnWindowFocus = false, } = {}) {
|
|
74
35
|
return {
|
|
75
36
|
queries: {
|
|
76
37
|
staleTime,
|
package/dist/query.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,IAAI,QAAQ,EAAE,WAAW,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAEzF,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AA+D1C;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CACzB,KAAc,EACd,oBAAuC,EAAE,EACzC,gBAAyB;IAEzB,gGAAgG;IAChG,OAAO,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE;QAChC,UAAU,EAAE,IAAI;QAChB,YAAY,EAAE,iBAAiB;QAC/B,WAAW,EAAE,gBAAgB;KAC9B,CAAC,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,YAAoB,EAAE,KAAc;IAC/D,OAAO,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;AACjD,CAAC;AAED;;;;;GAKG;AACH,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,KAAK,YAAY,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;AAClD,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,EAC5B,iBAAiB,GAAG,EAAE,EACtB,UAAU,GAAG,CAAC,EACd,gBAAgB,EAChB,SAAS,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,EACzB,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,EACvB,oBAAoB,GAAG,KAAK,MACJ,EAAE;IAC1B,OAAO;QACL,OAAO,EAAE;YACP,SAAS;YACT,MAAM;YACN,KAAK,EAAE,CAAC,YAAY,EAAE,KAAK,EAAE,EAAE,CAC7B,YAAY,GAAG,UAAU,IAAI,WAAW,CAAC,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,CAAC;YACtF,UAAU,EAAE,YAAY;YACxB,oBAAoB;YACpB,kBAAkB,EAAE,IAAI;SACzB;QACD,2FAA2F;QAC3F,yFAAyF;QACzF,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;KAC5B,CAAC;AACJ,CAAC;AAkCD,MAAM,UAAU,SAAS,CAAO,KAG/B;IACC,MAAM,EAAE,IAAI,EAAE,GAAG,KAAK,CAAC;IACvB,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,IAAI,IAAI,CAAC;IAClC,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC;IAC7E,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IACtD,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;AAC9B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14",
|
|
4
4
|
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"release:major": "bun pm version major && bun publish --access public"
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
|
-
"@gusnips/http": "0.1.
|
|
83
|
+
"@gusnips/http": "0.1.2",
|
|
84
84
|
"clsx": "^2.1.1",
|
|
85
85
|
"tailwind-merge": "^3.6.0"
|
|
86
86
|
},
|
package/src/api-client.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { retryAfterSecs } from "@gusnips/http/retry";
|
|
3
|
+
import { ApiError, isAbortError } from "./api-error.ts";
|
|
3
4
|
import { createApiClient, type RefreshResult, type SessionAdapter } from "./api-client.ts";
|
|
4
5
|
|
|
5
6
|
function stubFetch(handler: (url: string, init: RequestInit) => Response | Promise<Response>) {
|
|
@@ -89,7 +90,7 @@ describe("createApiClient", () => {
|
|
|
89
90
|
|
|
90
91
|
const error = await api.get("/x").catch((e: unknown) => e);
|
|
91
92
|
expect(error).toMatchObject({ status: 429, retryAfterSecs: 60 });
|
|
92
|
-
expect(retryAfterSecs(error
|
|
93
|
+
expect(retryAfterSecs(error)).toBe(60);
|
|
93
94
|
});
|
|
94
95
|
|
|
95
96
|
it("leaves the wait unset when the answer did not state one", async () => {
|
|
@@ -102,7 +103,7 @@ describe("createApiClient", () => {
|
|
|
102
103
|
|
|
103
104
|
const error = await api.get("/x").catch((e: unknown) => e);
|
|
104
105
|
expect((error as ApiError).retryAfterSecs).toBeUndefined();
|
|
105
|
-
expect(retryAfterSecs(error
|
|
106
|
+
expect(retryAfterSecs(error)).toBeNull();
|
|
106
107
|
});
|
|
107
108
|
|
|
108
109
|
/**
|
package/src/api-client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isApiError, type ApiSuccess } from "@gusnips/http";
|
|
2
|
-
import {
|
|
2
|
+
import { parseRetryAfter } from "@gusnips/http/retry";
|
|
3
|
+
import { ApiError } from "./api-error.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The app's one door to the API. Nothing else should call `fetch`.
|
package/src/api-error.ts
CHANGED
|
@@ -26,7 +26,7 @@ export class ApiError extends Error {
|
|
|
26
26
|
* `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader —
|
|
27
27
|
* which looked in `details.retryAfterSecs`, the first donor's convention — found nothing, and
|
|
28
28
|
* both the copy and the retry rule were flying blind on the one refusal that states its own
|
|
29
|
-
* expiry. Two conventions, same fact;
|
|
29
|
+
* expiry. Two conventions, same fact; `retryAfterSecs` from `@gusnips/http/retry` reads either.
|
|
30
30
|
*/
|
|
31
31
|
readonly retryAfterSecs: number | undefined;
|
|
32
32
|
|
|
@@ -64,76 +64,6 @@ export class ApiError extends Error {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
* Seconds until a refusal clears, from whichever place the API put them.
|
|
69
|
-
*
|
|
70
|
-
* The header first, because it is the standard one and it is what a limiter emits without being
|
|
71
|
-
* asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
|
|
72
|
-
* caller asking "how long" should not have to know which server it is talking to.
|
|
73
|
-
*
|
|
74
|
-
* It deliberately does not answer whether waiting helps at all. `null` here means "no countdown
|
|
75
|
-
* to show", which is the right answer both for a refusal that said nothing and for one that said
|
|
76
|
-
* waiting will never fix it — and the adopters' countdown UIs read it for exactly that. Telling
|
|
77
|
-
* those two apart is {@link waitingNeverHelps}.
|
|
78
|
-
*/
|
|
79
|
-
export function retryAfterSecs(error: ApiError): number | null {
|
|
80
|
-
if (error.retryAfterSecs !== undefined) return error.retryAfterSecs;
|
|
81
|
-
const details = error.details;
|
|
82
|
-
if (typeof details !== "object" || details === null) return null;
|
|
83
|
-
const value = (details as { retryAfterSecs?: unknown }).retryAfterSecs;
|
|
84
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Did the answer say, in so many words, that waiting will never fix this?
|
|
89
|
-
*
|
|
90
|
-
* The server half of this fleet sends `details.retryAfterSecs: null` on a refusal no amount of
|
|
91
|
-
* time clears — a cap that frees only when somebody deletes something, a concurrency slot that
|
|
92
|
-
* frees when another job ends. An explicit `null` is a claim somebody has to read; an omitted key
|
|
93
|
-
* is invisible. That is the whole reason the two must not collapse into one answer: this is the
|
|
94
|
-
* server naming what `durableLimitCodes` otherwise makes every app name by hand, one code at a
|
|
95
|
-
* time, and keep in step with a backend it does not own.
|
|
96
|
-
*
|
|
97
|
-
* Present-and-`null` is the entire test. An absent key reads as `undefined`, so `=== null`
|
|
98
|
-
* already separates the claim from the silence and an `in` check would only widen it to every
|
|
99
|
-
* stated wait. Body-only by design: `Retry-After` has no spelling for "never".
|
|
100
|
-
*
|
|
101
|
-
* Not exported from the package, and that is the point: it is not "is this refusal durable?". It
|
|
102
|
-
* says nothing about a durable CODE or a wait too long to hold, so an error surface branching on
|
|
103
|
-
* it would answer differently from the retry rule for every limit the app names itself. The one
|
|
104
|
-
* question worth asking out loud is `shouldRetry`, and `describeError` already derives the
|
|
105
|
-
* control it offers from exactly that — invariant 8, one rule and two consumers.
|
|
106
|
-
*/
|
|
107
|
-
export function waitingNeverHelps(error: ApiError): boolean {
|
|
108
|
-
const details = error.details;
|
|
109
|
-
if (typeof details !== "object" || details === null) return false;
|
|
110
|
-
return (details as { retryAfterSecs?: unknown }).retryAfterSecs === null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
|
|
115
|
-
*
|
|
116
|
-
* Both forms are real — RFC 9110 allows either and different servers pick differently — and the
|
|
117
|
-
* date one is the half that gets skipped, which turns a stated wait into no wait at all. Clamped
|
|
118
|
-
* at zero: a date already in the past means "now", not a negative delay.
|
|
119
|
-
*/
|
|
120
|
-
export function parseRetryAfter(value: string | null | undefined): number | undefined {
|
|
121
|
-
const raw = value?.trim();
|
|
122
|
-
if (!raw) return undefined;
|
|
123
|
-
|
|
124
|
-
// A bare integer is the delay-seconds form, malformed included: `Date.parse` accepts some of
|
|
125
|
-
// them as years, so falling through would read "-5" as a date long past and answer 0 — a
|
|
126
|
-
// wait invented out of a header that never stated one.
|
|
127
|
-
if (/^[+-]?\d+$/.test(raw)) {
|
|
128
|
-
const secs = Number(raw);
|
|
129
|
-
return Number.isSafeInteger(secs) && secs >= 0 ? secs : undefined;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const at = Date.parse(raw);
|
|
133
|
-
if (Number.isNaN(at)) return undefined;
|
|
134
|
-
return Math.max(0, Math.round((at - Date.now()) / 1000));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
67
|
/**
|
|
138
68
|
* An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
|
|
139
69
|
* component waiting on it.
|
package/src/copy.test.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { copyText } from "./copy.ts";
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe("copyText", () => {
|
|
9
|
+
it("answers true once the clipboard has the text", async () => {
|
|
10
|
+
const writeText = vi.fn(() => Promise.resolve());
|
|
11
|
+
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
|
12
|
+
await expect(copyText("sk_live_1")).resolves.toBe(true);
|
|
13
|
+
expect(writeText).toHaveBeenCalledWith("sk_live_1");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("answers false when the browser refuses the write", async () => {
|
|
17
|
+
vi.stubGlobal("navigator", {
|
|
18
|
+
clipboard: { writeText: () => Promise.reject(new DOMException("denied", "NotAllowedError")) },
|
|
19
|
+
});
|
|
20
|
+
await expect(copyText("sk_live_1")).resolves.toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Over plain http there is no `navigator.clipboard`, so `writeText` is read off `undefined` and
|
|
24
|
+
// throws before any promise exists. Two copies in the fleet let that escape the click handler.
|
|
25
|
+
it("answers false, and does not throw, where there is no clipboard API", async () => {
|
|
26
|
+
vi.stubGlobal("navigator", {});
|
|
27
|
+
await expect(copyText("sk_live_1")).resolves.toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/copy.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// Eight products wrote a copy button, and five of them failed silently, one under a comment
|
|
4
|
+
// saying it must not. Silent is worse than it looks: the reader pastes whatever was already on
|
|
5
|
+
// the clipboard, and for a key shown once that is pasting something else over the only copy.
|
|
6
|
+
// Two more called `writeText` outside a `try`, and on a page not served over https there is no
|
|
7
|
+
// `navigator.clipboard` at all, so the click threw a TypeError instead of rejecting.
|
|
8
|
+
//
|
|
9
|
+
// No `execCommand` fallback, and none of the eight had one: it is deprecated, the async API is
|
|
10
|
+
// there in every browser over https, and a copy that says it failed beats a shim that pretends.
|
|
11
|
+
|
|
12
|
+
/** Write `text` to the clipboard. `false` when the browser refused or has no clipboard API. */
|
|
13
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
14
|
+
try {
|
|
15
|
+
await navigator.clipboard.writeText(text);
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CopyState = "idle" | "copied" | "failed";
|
|
23
|
+
|
|
24
|
+
export interface CopyOptions {
|
|
25
|
+
/** How long `"copied"` shows. Default 2000 ms. */
|
|
26
|
+
copiedMs?: number;
|
|
27
|
+
/** How long `"failed"` shows. Default 6000 ms: a failure is a sentence the reader has to act on. */
|
|
28
|
+
failedMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A copy with an outcome the reader can see. What it says stays yours — "Copied", or "Select it
|
|
33
|
+
* and copy it yourself", in a label or a toast — and so does announcing it: a label that changes
|
|
34
|
+
* needs `aria-live="polite"` to be heard.
|
|
35
|
+
*
|
|
36
|
+
* The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
|
|
37
|
+
* look like it did not take.
|
|
38
|
+
*/
|
|
39
|
+
export function useCopy({ copiedMs = 2000, failedMs = 6000 }: CopyOptions = {}): {
|
|
40
|
+
state: CopyState;
|
|
41
|
+
/** Resolves `false` instead of throwing, so a caller can toast without a `catch`. */
|
|
42
|
+
copy: (text: string) => Promise<boolean>;
|
|
43
|
+
} {
|
|
44
|
+
// A new object on every copy, so copying again restarts the clock even when the state is the
|
|
45
|
+
// same: two copies a second apart read "Copied" for two seconds after the second one.
|
|
46
|
+
const [outcome, setOutcome] = useState<{ state: CopyState }>({ state: "idle" });
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (outcome.state === "idle") return;
|
|
50
|
+
const ms = outcome.state === "copied" ? copiedMs : failedMs;
|
|
51
|
+
const timer = setTimeout(() => setOutcome({ state: "idle" }), ms);
|
|
52
|
+
return () => clearTimeout(timer);
|
|
53
|
+
}, [outcome, copiedMs, failedMs]);
|
|
54
|
+
|
|
55
|
+
const copy = useCallback(async (text: string) => {
|
|
56
|
+
const copied = await copyText(text);
|
|
57
|
+
setOutcome({ state: copied ? "copied" : "failed" });
|
|
58
|
+
return copied;
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
return { state: outcome.state, copy };
|
|
62
|
+
}
|
package/src/describe-error.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
// identical in source.
|
|
32
32
|
|
|
33
33
|
export { cn } from "./cn.ts";
|
|
34
|
+
export { copyText, useCopy, type CopyOptions, type CopyState } from "./copy.ts";
|
|
34
35
|
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
35
36
|
|
|
36
|
-
export { ApiError, isAbortError
|
|
37
|
+
export { ApiError, isAbortError } from "./api-error.ts";
|
|
38
|
+
export { retryAfterSecs } from "@gusnips/http/retry";
|
|
37
39
|
export {
|
|
38
40
|
createApiClient,
|
|
39
41
|
type ApiClient,
|
package/src/query.test.ts
CHANGED
|
@@ -26,6 +26,17 @@ describe("shouldRetry", () => {
|
|
|
26
26
|
expect(shouldRetry(new TypeError("Failed to fetch"))).toBe(true);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
// The describer reads anything that is not an `ApiError` as a request that never landed and
|
|
30
|
+
// offers "try again". A vendor's error carrying a `status` of its own must get the same answer
|
|
31
|
+
// here, or the button and the retry disagree.
|
|
32
|
+
it("reads only its own ApiError as an answer", () => {
|
|
33
|
+
const vendor = Object.assign(new Error("Invalid login credentials"), { status: 400 });
|
|
34
|
+
expect(shouldRetry(vendor)).toBe(true);
|
|
35
|
+
expect(retryDelayMs(0, Object.assign(new Error("x"), { status: 429, retryAfterSecs: 5 }))).toBe(
|
|
36
|
+
1000,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
29
40
|
it("retries 5xx", () => {
|
|
30
41
|
expect(shouldRetry(refusal(500))).toBe(true);
|
|
31
42
|
expect(shouldRetry(refusal(503))).toBe(true);
|
package/src/query.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { retryDelayMs as delayFor, shouldRetry as retryRule } from "@gusnips/http/retry";
|
|
1
2
|
import type { DefaultOptions } from "@tanstack/react-query";
|
|
2
|
-
import { ApiError
|
|
3
|
+
import { ApiError } from "./api-error.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* react-query defaults — the merge of three repos that each got part of this right.
|
|
@@ -26,14 +27,10 @@ import { ApiError, retryAfterSecs, waitingNeverHelps } from "./api-error.ts";
|
|
|
26
27
|
* "did not say how long" — which is what every version before this one did — that 429 is retried
|
|
27
28
|
* against a limit no amount of time moves.
|
|
28
29
|
*
|
|
29
|
-
* This is invariant 8.
|
|
30
|
+
* This is invariant 8. The rule itself lives in `@gusnips/http/retry`, so an SDK with no React
|
|
31
|
+
* in it follows the same one; what is here is the binding to react-query.
|
|
30
32
|
*/
|
|
31
33
|
|
|
32
|
-
/** 4xx statuses a second attempt can fix. Everything else in the 4xx family is an answer. */
|
|
33
|
-
const RETRYABLE_CLIENT_STATUS = new Set([408, 429]);
|
|
34
|
-
|
|
35
|
-
const DEFAULT_MAX_RETRY_WAIT_SECS = 10;
|
|
36
|
-
|
|
37
34
|
export interface QueryDefaultsOptions {
|
|
38
35
|
/**
|
|
39
36
|
* Error codes that mean "this limit does not clear by waiting" — a spent monthly quota, a
|
|
@@ -70,33 +67,19 @@ export interface QueryDefaultsOptions {
|
|
|
70
67
|
* Should this failure be retried?
|
|
71
68
|
*
|
|
72
69
|
* Exported on its own because an app with its own QueryClient config still wants this rule, and
|
|
73
|
-
* because
|
|
70
|
+
* because the describer derives the control it offers from it.
|
|
74
71
|
*/
|
|
75
72
|
export function shouldRetry(
|
|
76
73
|
error: unknown,
|
|
77
74
|
durableLimitCodes: readonly string[] = [],
|
|
78
|
-
maxRetryWaitSecs
|
|
75
|
+
maxRetryWaitSecs?: number,
|
|
79
76
|
): boolean {
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// The same judgement, made by the server instead of by a list the app maintains: an explicit
|
|
87
|
-
// `details.retryAfterSecs: null` means waiting will never fix this. Checked before the stated
|
|
88
|
-
// wait because the two can contradict each other, and the `null` is written by whoever raised
|
|
89
|
-
// this particular refusal where a number can come from a limiter that attaches one to every
|
|
90
|
-
// 429 it emits.
|
|
91
|
-
if (waitingNeverHelps(error)) return false;
|
|
92
|
-
|
|
93
|
-
// It told us when it clears. A wait we are not willing to hold is a refusal, not a hiccup —
|
|
94
|
-
// and it is the same judgement `durableLimitCodes` makes, except the server did the naming.
|
|
95
|
-
const wait = retryAfterSecs(error);
|
|
96
|
-
if (wait !== null && wait > maxRetryWaitSecs) return false;
|
|
97
|
-
|
|
98
|
-
if (error.status >= 500) return true;
|
|
99
|
-
return RETRYABLE_CLIENT_STATUS.has(error.status);
|
|
77
|
+
// Every caller here is a query, or a person pressing "try again", so the request may run twice.
|
|
78
|
+
return retryRule(answerOf(error), {
|
|
79
|
+
repeatable: true,
|
|
80
|
+
durableCodes: durableLimitCodes,
|
|
81
|
+
maxWaitSecs: maxRetryWaitSecs,
|
|
82
|
+
});
|
|
100
83
|
}
|
|
101
84
|
|
|
102
85
|
/**
|
|
@@ -105,16 +88,23 @@ export function shouldRetry(
|
|
|
105
88
|
* server saying "immediately", which for a client that just got refused is still too soon.
|
|
106
89
|
*/
|
|
107
90
|
export function retryDelayMs(attemptIndex: number, error: unknown): number {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
91
|
+
return delayFor(attemptIndex, answerOf(error));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Only this client's `ApiError` is an answer. The shared rule reads any error by its fields, so a
|
|
96
|
+
* vendor's error with a `status` of its own would count as one — and the describer reads anything
|
|
97
|
+
* that is not an `ApiError` as a request that never landed, offering "try again" where the retry
|
|
98
|
+
* rule would refuse. Handing the rule `null` keeps the two reading one failure the same way.
|
|
99
|
+
*/
|
|
100
|
+
function answerOf(error: unknown): ApiError | null {
|
|
101
|
+
return error instanceof ApiError ? error : null;
|
|
112
102
|
}
|
|
113
103
|
|
|
114
104
|
export function queryDefaults({
|
|
115
105
|
durableLimitCodes = [],
|
|
116
106
|
maxRetries = 2,
|
|
117
|
-
maxRetryWaitSecs
|
|
107
|
+
maxRetryWaitSecs,
|
|
118
108
|
staleTime = 2 * 60 * 1000,
|
|
119
109
|
gcTime = 10 * 60 * 1000,
|
|
120
110
|
refetchOnWindowFocus = false,
|
package/src/api-error.test.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { ApiError, parseRetryAfter, retryAfterSecs, waitingNeverHelps } from "./api-error.ts";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Two APIs, two places for the same fact: one states the wait in the `Retry-After` header (the
|
|
6
|
-
* standard one), the other in `details.retryAfterSecs`. A caller asking "how long" should not
|
|
7
|
-
* have to know which server answered.
|
|
8
|
-
*/
|
|
9
|
-
describe("retryAfterSecs", () => {
|
|
10
|
-
it("reads the header the client captured", () => {
|
|
11
|
-
expect(retryAfterSecs(new ApiError(429, null, { retryAfterSecs: 60 }))).toBe(60);
|
|
12
|
-
// Zero is a real answer — "now" — and must not read as "did not say".
|
|
13
|
-
expect(retryAfterSecs(new ApiError(429, null, { retryAfterSecs: 0 }))).toBe(0);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("prefers the header to the body", () => {
|
|
17
|
-
const both = new ApiError(
|
|
18
|
-
429,
|
|
19
|
-
{ code: "X", message: "", details: { retryAfterSecs: 30 } },
|
|
20
|
-
{
|
|
21
|
-
retryAfterSecs: 60,
|
|
22
|
-
},
|
|
23
|
-
);
|
|
24
|
-
expect(retryAfterSecs(both)).toBe(60);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("reads the number out of details, and refuses anything else", () => {
|
|
28
|
-
expect(
|
|
29
|
-
retryAfterSecs(
|
|
30
|
-
new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: 30 } }),
|
|
31
|
-
),
|
|
32
|
-
).toBe(30);
|
|
33
|
-
expect(
|
|
34
|
-
retryAfterSecs(
|
|
35
|
-
new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: "30" } }),
|
|
36
|
-
),
|
|
37
|
-
).toBeNull();
|
|
38
|
-
expect(retryAfterSecs(new ApiError(429, { code: "X", message: "", details: null }))).toBeNull();
|
|
39
|
-
expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// And it keeps answering `null` for a stated never, which is what its callers want: there is
|
|
43
|
-
// no countdown to show. The claim is a separate question, asked separately.
|
|
44
|
-
it("answers null for a stated never, same as for silence", () => {
|
|
45
|
-
const stated = new ApiError(429, {
|
|
46
|
-
code: "X",
|
|
47
|
-
message: "",
|
|
48
|
-
details: { retryAfterSecs: null },
|
|
49
|
-
});
|
|
50
|
-
expect(retryAfterSecs(stated)).toBeNull();
|
|
51
|
-
expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* The claim `retryAfterSecs` cannot carry: an explicit `null` in the body, meaning waiting will
|
|
57
|
-
* never fix this. It has to be told apart from an absent key, which is silence.
|
|
58
|
-
*/
|
|
59
|
-
describe("waitingNeverHelps", () => {
|
|
60
|
-
const withDetails = (details: unknown) => new ApiError(429, { code: "X", message: "", details });
|
|
61
|
-
|
|
62
|
-
it("reads a present null as the claim", () => {
|
|
63
|
-
expect(waitingNeverHelps(withDetails({ retryAfterSecs: null }))).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("reads silence as no claim", () => {
|
|
67
|
-
expect(waitingNeverHelps(withDetails({ scope: "account" }))).toBe(false);
|
|
68
|
-
expect(waitingNeverHelps(withDetails({}))).toBe(false);
|
|
69
|
-
expect(waitingNeverHelps(new ApiError(429, null))).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("reads a stated wait as no claim", () => {
|
|
73
|
-
expect(waitingNeverHelps(withDetails({ retryAfterSecs: 30 }))).toBe(false);
|
|
74
|
-
// The header is not a channel for this — there is no `Retry-After` that says "never".
|
|
75
|
-
expect(waitingNeverHelps(new ApiError(429, null, { retryAfterSecs: 30 }))).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// `details` is whatever the server put there, so the shape is a trust boundary. A JSON `null`
|
|
79
|
-
// is the one that bites: `typeof null` is `"object"`, so a missing guard reads a property off
|
|
80
|
-
// it and throws inside a retry rule.
|
|
81
|
-
it("survives a details that is not an object", () => {
|
|
82
|
-
expect(waitingNeverHelps(withDetails(null))).toBe(false);
|
|
83
|
-
expect(waitingNeverHelps(withDetails("nope"))).toBe(false);
|
|
84
|
-
expect(waitingNeverHelps(withDetails(7))).toBe(false);
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Both spec forms are real — RFC 9110 allows delay-seconds or an HTTP-date, and servers pick
|
|
90
|
-
* differently. The date half is the one that gets skipped, which silently turns a stated wait
|
|
91
|
-
* into no wait at all.
|
|
92
|
-
*/
|
|
93
|
-
describe("parseRetryAfter", () => {
|
|
94
|
-
it("reads delay-seconds", () => {
|
|
95
|
-
expect(parseRetryAfter("60")).toBe(60);
|
|
96
|
-
expect(parseRetryAfter(" 60 ")).toBe(60);
|
|
97
|
-
expect(parseRetryAfter("0")).toBe(0);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("reads an HTTP-date", () => {
|
|
101
|
-
const at = new Date(Date.now() + 120_000).toUTCString();
|
|
102
|
-
expect(parseRetryAfter(at)).toBeGreaterThanOrEqual(119);
|
|
103
|
-
expect(parseRetryAfter(at)).toBeLessThanOrEqual(120);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// A date already gone means "now", not a negative delay that would run the backoff backwards.
|
|
107
|
-
it("clamps a date in the past to zero", () => {
|
|
108
|
-
expect(parseRetryAfter(new Date(Date.now() - 60_000).toUTCString())).toBe(0);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("answers nothing for a header that is missing or not a wait", () => {
|
|
112
|
-
expect(parseRetryAfter(null)).toBeUndefined();
|
|
113
|
-
expect(parseRetryAfter("")).toBeUndefined();
|
|
114
|
-
expect(parseRetryAfter(" ")).toBeUndefined();
|
|
115
|
-
expect(parseRetryAfter("soon")).toBeUndefined();
|
|
116
|
-
expect(parseRetryAfter("-5")).toBeUndefined();
|
|
117
|
-
expect(parseRetryAfter("9007199254740993")).toBeUndefined();
|
|
118
|
-
});
|
|
119
|
-
});
|