@gusnips/react 0.1.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/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/api-client.d.ts +139 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +133 -0
- package/dist/api-client.js.map +1 -0
- package/dist/api-error.d.ts +45 -0
- package/dist/api-error.d.ts.map +1 -0
- package/dist/api-error.js +52 -0
- package/dist/api-error.js.map +1 -0
- package/dist/auth-store.d.ts +49 -0
- package/dist/auth-store.d.ts.map +1 -0
- package/dist/auth-store.js +27 -0
- package/dist/auth-store.js.map +1 -0
- package/dist/chunk-reload.d.ts +61 -0
- package/dist/chunk-reload.d.ts.map +1 -0
- package/dist/chunk-reload.js +92 -0
- package/dist/chunk-reload.js.map +1 -0
- package/dist/cn.d.ts +13 -0
- package/dist/cn.d.ts.map +1 -0
- package/dist/cn.js +16 -0
- package/dist/cn.js.map +1 -0
- package/dist/describe-error.d.ts +74 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +70 -0
- package/dist/describe-error.js.map +1 -0
- package/dist/error-boundary.d.ts +61 -0
- package/dist/error-boundary.d.ts.map +1 -0
- package/dist/error-boundary.js +32 -0
- package/dist/error-boundary.js.map +1 -0
- package/dist/guards.d.ts +65 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +52 -0
- package/dist/guards.js.map +1 -0
- package/dist/hydrate.d.ts +30 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/hydrate.js +25 -0
- package/dist/hydrate.js.map +1 -0
- package/dist/i18n.d.ts +70 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +66 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/prerender-contract.d.ts +30 -0
- package/dist/prerender-contract.d.ts.map +1 -0
- package/dist/prerender-contract.js +30 -0
- package/dist/prerender-contract.js.map +1 -0
- package/dist/query.d.ts +28 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +51 -0
- package/dist/query.js.map +1 -0
- package/dist/sse.d.ts +64 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +105 -0
- package/dist/sse.js.map +1 -0
- package/dist/states.d.ts +69 -0
- package/dist/states.d.ts.map +1 -0
- package/dist/states.js +2 -0
- package/dist/states.js.map +1 -0
- package/dist/ui/class-name.d.ts +16 -0
- package/dist/ui/class-name.d.ts.map +1 -0
- package/dist/ui/class-name.js +13 -0
- package/dist/ui/class-name.js.map +1 -0
- package/dist/ui/combobox.d.ts +81 -0
- package/dist/ui/combobox.d.ts.map +1 -0
- package/dist/ui/combobox.js +70 -0
- package/dist/ui/combobox.js.map +1 -0
- package/dist/ui/dialog.d.ts +39 -0
- package/dist/ui/dialog.d.ts.map +1 -0
- package/dist/ui/dialog.js +32 -0
- package/dist/ui/dialog.js.map +1 -0
- package/dist/ui/drawer.d.ts +63 -0
- package/dist/ui/drawer.d.ts.map +1 -0
- package/dist/ui/drawer.js +55 -0
- package/dist/ui/drawer.js.map +1 -0
- package/dist/ui/glyphs.d.ts +4 -0
- package/dist/ui/glyphs.d.ts.map +1 -0
- package/dist/ui/glyphs.js +20 -0
- package/dist/ui/glyphs.js.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +48 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/input.d.ts +45 -0
- package/dist/ui/input.d.ts.map +1 -0
- package/dist/ui/input.js +108 -0
- package/dist/ui/input.js.map +1 -0
- package/dist/ui/menu.d.ts +70 -0
- package/dist/ui/menu.d.ts.map +1 -0
- package/dist/ui/menu.js +85 -0
- package/dist/ui/menu.js.map +1 -0
- package/dist/ui/select.d.ts +69 -0
- package/dist/ui/select.d.ts.map +1 -0
- package/dist/ui/select.js +48 -0
- package/dist/ui/select.js.map +1 -0
- package/dist/ui/tabs.d.ts +52 -0
- package/dist/ui/tabs.d.ts.map +1 -0
- package/dist/ui/tabs.js +62 -0
- package/dist/ui/tabs.js.map +1 -0
- package/package.json +124 -0
- package/src/api-client.test.ts +256 -0
- package/src/api-client.ts +305 -0
- package/src/api-error.ts +60 -0
- package/src/auth-store.ts +59 -0
- package/src/chunk-reload.test.ts +46 -0
- package/src/chunk-reload.ts +94 -0
- package/src/cn.ts +16 -0
- package/src/describe-error.test.ts +102 -0
- package/src/describe-error.ts +140 -0
- package/src/error-boundary.tsx +88 -0
- package/src/guards.tsx +99 -0
- package/src/hydrate.ts +36 -0
- package/src/i18n.test.ts +68 -0
- package/src/i18n.ts +101 -0
- package/src/index.ts +63 -0
- package/src/prerender-contract.ts +31 -0
- package/src/query.test.ts +54 -0
- package/src/query.ts +79 -0
- package/src/sse.test.ts +50 -0
- package/src/sse.ts +117 -0
- package/src/states.ts +71 -0
- package/src/ui/class-name.ts +23 -0
- package/src/ui/combobox.test.ts +32 -0
- package/src/ui/combobox.tsx +242 -0
- package/src/ui/dialog.tsx +87 -0
- package/src/ui/drawer.tsx +161 -0
- package/src/ui/focus.test.ts +82 -0
- package/src/ui/glyphs.tsx +35 -0
- package/src/ui/index.ts +145 -0
- package/src/ui/input.test.ts +22 -0
- package/src/ui/input.tsx +181 -0
- package/src/ui/menu.test.ts +73 -0
- package/src/ui/menu.tsx +186 -0
- package/src/ui/select.tsx +191 -0
- package/src/ui/tabs.tsx +95 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { isApiError, type ApiSuccess } from "@gusnips/http";
|
|
2
|
+
import { ApiError } from "./api-error.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The app's one door to the API. Nothing else should call `fetch`.
|
|
6
|
+
*
|
|
7
|
+
* Every route answers the same envelope, so unwrapping and error-shaping belong here once
|
|
8
|
+
* rather than in every hook. Three donors built this independently and all three arrived at
|
|
9
|
+
* single-flight token refresh — see {@link SessionAdapter.refresh} for the reason, which is
|
|
10
|
+
* the sharpest thing in this file.
|
|
11
|
+
*
|
|
12
|
+
* Four things deliberately did NOT come across from the donors, because they belong to a
|
|
13
|
+
* product and not to a client:
|
|
14
|
+
*
|
|
15
|
+
* - **Toasts.** Whether a failure is spoken aloud is a design decision that differs per surface.
|
|
16
|
+
* Throw, and let the caller decide.
|
|
17
|
+
* - **In-flight GET deduplication.** react-query already dedups by key, so the client's copy is
|
|
18
|
+
* redundant — and one donor's version returned a `clone()` of a body already being consumed,
|
|
19
|
+
* which is a race with no symptom until it has one.
|
|
20
|
+
* - **Service-status tracking and impersonation.** One donor's, and firmly its own.
|
|
21
|
+
* - **Analytics correlation headers.** Injected through {@link ApiClientOptions.headers}.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What a refresh attempt actually learned. */
|
|
25
|
+
export interface RefreshResult {
|
|
26
|
+
token: string | null;
|
|
27
|
+
/**
|
|
28
|
+
* False when the refresh never got an ANSWER out of the auth server — a dropped connection,
|
|
29
|
+
* a 5xx, a captive portal.
|
|
30
|
+
*
|
|
31
|
+
* This flag is invariant 3, and only one of three donors had it. Losing a packet says nothing
|
|
32
|
+
* about whether a session is still good, so a client that collapses "auth said no" into
|
|
33
|
+
* "auth did not answer" signs people out over a Wi-Fi blip and costs them their place for
|
|
34
|
+
* nothing. Auth libraries usually name this: Supabase throws `AuthRetryableFetchError`, so
|
|
35
|
+
* an adapter answers `reachedAuth: !(error instanceof AuthRetryableFetchError)`.
|
|
36
|
+
*/
|
|
37
|
+
reachedAuth: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The seam onto whatever holds the session. Supabase in every donor so far, but nothing here
|
|
42
|
+
* knows that — an adapter is four lines and keeps the auth vendor out of this package.
|
|
43
|
+
*/
|
|
44
|
+
export interface SessionAdapter {
|
|
45
|
+
getToken(): Promise<string | null>;
|
|
46
|
+
refresh(): Promise<RefreshResult>;
|
|
47
|
+
signOut(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ApiClientOptions {
|
|
51
|
+
/** Origin + prefix, e.g. `https://api.example.com/v1`. No trailing slash. */
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
session: SessionAdapter;
|
|
54
|
+
/**
|
|
55
|
+
* Extra headers, computed per request.
|
|
56
|
+
*
|
|
57
|
+
* A function, not an object, and that matters: one donor read
|
|
58
|
+
* `document.documentElement.lang` inline in its request builder, which crashes a prerender
|
|
59
|
+
* outright. Anything that touches the DOM, the current locale or the clock goes in here,
|
|
60
|
+
* where it runs only when a request is actually being sent.
|
|
61
|
+
*/
|
|
62
|
+
headers?: () => Record<string, string>;
|
|
63
|
+
/**
|
|
64
|
+
* The session is gone and cannot be renewed — send them to sign in.
|
|
65
|
+
*
|
|
66
|
+
* The client does not know the route, and it deliberately still THROWS after calling this:
|
|
67
|
+
* `location.replace()` does not stop the current frame, so a caller's own `onError` must
|
|
68
|
+
* still run or a half-finished screen keeps rendering against data that will never arrive.
|
|
69
|
+
* The error it throws carries `expected: true`.
|
|
70
|
+
*/
|
|
71
|
+
onSessionDead: () => void;
|
|
72
|
+
/**
|
|
73
|
+
* Every failed response, seen once, just before it is thrown.
|
|
74
|
+
*
|
|
75
|
+
* For a reaction that belongs to the whole app rather than to one call site. The donor's case
|
|
76
|
+
* is the sharp one: a mid-session account suspension 403s every authed route except
|
|
77
|
+
* `GET /auth/me`, so the moment one arrives the app has to refresh `me` and route to the
|
|
78
|
+
* screen that explains it — otherwise every query on the page fails at once and the shell
|
|
79
|
+
* half-renders behind an error storm until `me` goes stale on its own.
|
|
80
|
+
*
|
|
81
|
+
* It cannot live at a call site, because the point is that it fires from whichever call
|
|
82
|
+
* happened to be first. It went in the donor's client directly, which made the client import
|
|
83
|
+
* its query cache and its query keys — a cycle that this hook removes.
|
|
84
|
+
*
|
|
85
|
+
* Observation only: the error is thrown either way, and throwing from here would replace a
|
|
86
|
+
* real API failure with whatever the listener hit.
|
|
87
|
+
*/
|
|
88
|
+
onError?: (error: ApiError) => void;
|
|
89
|
+
/**
|
|
90
|
+
* Abort a request that has not answered. Default 30s.
|
|
91
|
+
*
|
|
92
|
+
* Neither donor bounded its authenticated requests at all — both bounded only their keyless
|
|
93
|
+
* clients — so a stalled connection was a spinner with no end. providerkit learned the same
|
|
94
|
+
* lesson about streams: a request with no deadline is a bug that only shows up on a bad
|
|
95
|
+
* network, which is exactly when nobody can reproduce it.
|
|
96
|
+
*/
|
|
97
|
+
timeoutMs?: number;
|
|
98
|
+
/** How many times to refresh-and-retry a 401. Default 2. */
|
|
99
|
+
maxRefreshAttempts?: number;
|
|
100
|
+
/** Pause between refresh attempts. Default 500ms. */
|
|
101
|
+
refreshRetryDelayMs?: number;
|
|
102
|
+
/**
|
|
103
|
+
* How long to wait for `signOut()` before redirecting anyway. Default 3s.
|
|
104
|
+
*
|
|
105
|
+
* A fail-safe, and one donor added it after the failure it prevents: awaiting `signOut()`
|
|
106
|
+
* covers a rejection but not a HANG, and a hang leaves the tab signed out in name only —
|
|
107
|
+
* still on the page, every request 401ing, nothing left that could redirect it.
|
|
108
|
+
*/
|
|
109
|
+
signOutTimeoutMs?: number;
|
|
110
|
+
/** Header carrying the server's request id, echoed onto {@link ApiError.requestId}. */
|
|
111
|
+
requestIdHeader?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface RequestOptions extends Omit<RequestInit, "headers"> {
|
|
115
|
+
headers?: Record<string, string>;
|
|
116
|
+
/** Override the client's default timeout for this call. `null` disables it (streams). */
|
|
117
|
+
timeoutMs?: number | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ApiClient {
|
|
121
|
+
/** The raw `Response`, past auth and error handling. For blobs, streams and downloads. */
|
|
122
|
+
request(path: string, options?: RequestOptions): Promise<Response>;
|
|
123
|
+
/** `data` out of the success envelope. */
|
|
124
|
+
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
125
|
+
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
|
|
126
|
+
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
|
|
127
|
+
patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
|
|
128
|
+
/**
|
|
129
|
+
* DELETE, discarding whatever comes back. Returns `void` because 204 is the usual answer and
|
|
130
|
+
* a 204 has NO BODY — `res.json()` on one throws `Unexpected end of JSON input`, which reads
|
|
131
|
+
* like a malformed response and is really just a success nobody was allowed to parse.
|
|
132
|
+
*
|
|
133
|
+
* Split from {@link ApiClient.delJson} rather than guessing per response, because the guess is
|
|
134
|
+
* the part that hides a bug: a route that was supposed to answer with data and returned
|
|
135
|
+
* nothing should fail loudly here, not hand back a silent `undefined` that surfaces three
|
|
136
|
+
* layers away. The donor that hit this had written exactly these two functions.
|
|
137
|
+
*/
|
|
138
|
+
del(path: string, options?: RequestOptions): Promise<void>;
|
|
139
|
+
/** The DELETE that answers with something worth reading — a queue entry it handed back. */
|
|
140
|
+
delJson<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
141
|
+
/** The whole envelope, for a list route whose counts live in `meta`. */
|
|
142
|
+
page<T, M>(path: string, options?: RequestOptions): Promise<ApiSuccess<T, M>>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
146
|
+
|
|
147
|
+
export function createApiClient({
|
|
148
|
+
baseUrl,
|
|
149
|
+
session,
|
|
150
|
+
headers: extraHeaders,
|
|
151
|
+
onSessionDead,
|
|
152
|
+
onError,
|
|
153
|
+
timeoutMs = 30_000,
|
|
154
|
+
maxRefreshAttempts = 2,
|
|
155
|
+
refreshRetryDelayMs = 500,
|
|
156
|
+
signOutTimeoutMs = 3_000,
|
|
157
|
+
requestIdHeader = "x-request-id",
|
|
158
|
+
}: ApiClientOptions): ApiClient {
|
|
159
|
+
/**
|
|
160
|
+
* Single-flight refresh.
|
|
161
|
+
*
|
|
162
|
+
* A page that fires six queries at once must not send six refreshes. The auth server rotates
|
|
163
|
+
* the refresh token on use, so the losers of that race each invalidate the winner — and the
|
|
164
|
+
* person is signed out in the middle of a load that was working. All three donors found this
|
|
165
|
+
* the hard way and all three fixed it the same way.
|
|
166
|
+
*/
|
|
167
|
+
let refreshing: Promise<RefreshResult> | null = null;
|
|
168
|
+
function refresh(): Promise<RefreshResult> {
|
|
169
|
+
refreshing ??= session
|
|
170
|
+
.refresh()
|
|
171
|
+
.catch(() => ({ token: null, reachedAuth: false }))
|
|
172
|
+
.finally(() => {
|
|
173
|
+
refreshing = null;
|
|
174
|
+
});
|
|
175
|
+
return refreshing;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Latches, so whichever request gets here first owns the sign-out and the rest are no-ops. */
|
|
179
|
+
let signingOut = false;
|
|
180
|
+
function sessionDead(): never {
|
|
181
|
+
if (!signingOut) {
|
|
182
|
+
signingOut = true;
|
|
183
|
+
let done = false;
|
|
184
|
+
const go = (): void => {
|
|
185
|
+
if (done) return;
|
|
186
|
+
done = true;
|
|
187
|
+
onSessionDead();
|
|
188
|
+
};
|
|
189
|
+
void session
|
|
190
|
+
.signOut()
|
|
191
|
+
.catch(() => {})
|
|
192
|
+
.finally(go);
|
|
193
|
+
setTimeout(go, signOutTimeoutMs);
|
|
194
|
+
}
|
|
195
|
+
// Thrown even though the redirect is under way: see `onSessionDead`.
|
|
196
|
+
throw new ApiError(
|
|
197
|
+
401,
|
|
198
|
+
{ code: "UNAUTHORIZED", message: "Session expired" },
|
|
199
|
+
{ expected: true },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildHeaders(token: string | null, extra?: Record<string, string>): Headers {
|
|
204
|
+
const headers = new Headers({ ...extraHeaders?.(), ...extra });
|
|
205
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
206
|
+
return headers;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function send(
|
|
210
|
+
path: string,
|
|
211
|
+
token: string | null,
|
|
212
|
+
{ headers, timeoutMs: perCall, signal, ...init }: RequestOptions,
|
|
213
|
+
): Promise<Response> {
|
|
214
|
+
const budget = perCall === undefined ? timeoutMs : perCall;
|
|
215
|
+
if (budget === null)
|
|
216
|
+
return fetch(`${baseUrl}${path}`, { ...init, signal, headers: buildHeaders(token, headers) });
|
|
217
|
+
|
|
218
|
+
// `AbortSignal.any` rather than a listener: it also catches the already-aborted race, where
|
|
219
|
+
// a caller's signal fired before we ever attached. It keeps the caller's abort and our
|
|
220
|
+
// timeout distinguishable at the source, which is what lets `isAbortError` stay honest.
|
|
221
|
+
const timeout = AbortSignal.timeout(budget);
|
|
222
|
+
return fetch(`${baseUrl}${path}`, {
|
|
223
|
+
...init,
|
|
224
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
225
|
+
headers: buildHeaders(token, headers),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function request(path: string, options: RequestOptions = {}): Promise<Response> {
|
|
230
|
+
let res = await send(path, await session.getToken(), options);
|
|
231
|
+
|
|
232
|
+
if (res.status === 401) {
|
|
233
|
+
// Only a refresh that actually REACHED auth proves the session is gone. Anything else is
|
|
234
|
+
// a network problem, and the 401 falls through as an ordinary error — the next request
|
|
235
|
+
// refreshes cleanly once the connection is back. Invariant 3.
|
|
236
|
+
let answered = false;
|
|
237
|
+
for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
|
|
238
|
+
if (attempt > 0) await sleep(refreshRetryDelayMs);
|
|
239
|
+
const result = await refresh();
|
|
240
|
+
answered = result.reachedAuth;
|
|
241
|
+
if (!result.token) continue;
|
|
242
|
+
res = await send(path, result.token, options);
|
|
243
|
+
if (res.status !== 401) break;
|
|
244
|
+
}
|
|
245
|
+
if (res.status === 401 && answered) sessionDead();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!res.ok) {
|
|
249
|
+
const body: unknown = await res.json().catch(() => null);
|
|
250
|
+
const error = new ApiError(res.status, isApiError(body) ? body.error : null, {
|
|
251
|
+
requestId: res.headers.get(requestIdHeader) ?? undefined,
|
|
252
|
+
});
|
|
253
|
+
// A listener that throws must not become the error the caller sees: the API failure is
|
|
254
|
+
// the real news, and swallowing it for a bug in a side effect would send everyone
|
|
255
|
+
// debugging the wrong thing.
|
|
256
|
+
try {
|
|
257
|
+
onError?.(error);
|
|
258
|
+
} catch {
|
|
259
|
+
// ignored on purpose — see above
|
|
260
|
+
}
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
return res;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The success envelope. `request` has already thrown on anything that is not one, so what is
|
|
268
|
+
* left is `{data, meta?}` by contract — this is the single place that assertion is made.
|
|
269
|
+
*/
|
|
270
|
+
async function envelope<T, M>(res: Response): Promise<ApiSuccess<T, M>> {
|
|
271
|
+
return (await res.json()) as ApiSuccess<T, M>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const json = (body: unknown, options: RequestOptions = {}): RequestOptions =>
|
|
275
|
+
body === undefined
|
|
276
|
+
? options
|
|
277
|
+
: {
|
|
278
|
+
...options,
|
|
279
|
+
body: JSON.stringify(body),
|
|
280
|
+
headers: { "Content-Type": "application/json", ...options.headers },
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
async function data<T>(path: string, options: RequestOptions): Promise<T> {
|
|
284
|
+
return (await envelope<T, never>(await request(path, options))).data;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
request,
|
|
289
|
+
get: <T>(path: string, options: RequestOptions = {}) =>
|
|
290
|
+
data<T>(path, { ...options, method: "GET" }),
|
|
291
|
+
post: <T>(path: string, body?: unknown, options: RequestOptions = {}) =>
|
|
292
|
+
data<T>(path, json(body, { ...options, method: "POST" })),
|
|
293
|
+
put: <T>(path: string, body?: unknown, options: RequestOptions = {}) =>
|
|
294
|
+
data<T>(path, json(body, { ...options, method: "PUT" })),
|
|
295
|
+
patch: <T>(path: string, body?: unknown, options: RequestOptions = {}) =>
|
|
296
|
+
data<T>(path, json(body, { ...options, method: "PATCH" })),
|
|
297
|
+
del: async (path: string, options: RequestOptions = {}) => {
|
|
298
|
+
await request(path, { ...options, method: "DELETE" });
|
|
299
|
+
},
|
|
300
|
+
delJson: <T>(path: string, options: RequestOptions = {}) =>
|
|
301
|
+
data<T>(path, { ...options, method: "DELETE" }),
|
|
302
|
+
page: async <T, M>(path: string, options: RequestOptions = {}) =>
|
|
303
|
+
envelope<T, M>(await request(path, { ...options, method: "GET" })),
|
|
304
|
+
};
|
|
305
|
+
}
|
package/src/api-error.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ApiError as ApiErrorBody } from "@gusnips/http";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A refusal, with the whole envelope intact.
|
|
5
|
+
*
|
|
6
|
+
* `code` is what the UI switches on, `messageKey` is what gets localized, and `details` carries
|
|
7
|
+
* the parts that make a refusal actionable — the `resetAt` on a 429, the plan that lifts a 402.
|
|
8
|
+
* Keeping all of it means a caller never has to re-parse a response body that has already been
|
|
9
|
+
* read once.
|
|
10
|
+
*
|
|
11
|
+
* A real `class` and not an interface with a factory: one donor used the interface shape, which
|
|
12
|
+
* works until something needs `instanceof` across a module boundary or wants a subclass.
|
|
13
|
+
*/
|
|
14
|
+
export class ApiError extends Error {
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly code: string | undefined;
|
|
17
|
+
readonly messageKey: string | undefined;
|
|
18
|
+
readonly params: Record<string, string | number> | undefined;
|
|
19
|
+
readonly details: unknown;
|
|
20
|
+
readonly requestId: string | undefined;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The client raised this itself to stop a caller while it was already handling the
|
|
24
|
+
* situation — today, the 401 thrown after a dead session, where the person is already on
|
|
25
|
+
* their way to the sign-in page.
|
|
26
|
+
*
|
|
27
|
+
* It is control flow, not a fault. Exception reporting should skip it, and an error surface
|
|
28
|
+
* should not ask somebody to retry something that is not broken. One donor had this and it is
|
|
29
|
+
* the difference between a clean sign-out and a crash report every time a session expires.
|
|
30
|
+
*/
|
|
31
|
+
readonly expected: boolean;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
status: number,
|
|
35
|
+
body: ApiErrorBody["error"] | null,
|
|
36
|
+
opts: { requestId?: string; expected?: boolean; message?: string } = {},
|
|
37
|
+
) {
|
|
38
|
+
super(opts.message ?? body?.message ?? `Request failed (${status})`);
|
|
39
|
+
this.name = "ApiError";
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.code = body?.code;
|
|
42
|
+
this.messageKey = body?.messageKey;
|
|
43
|
+
this.params = body?.params;
|
|
44
|
+
this.details = body?.details;
|
|
45
|
+
this.requestId = opts.requestId;
|
|
46
|
+
this.expected = opts.expected ?? false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
|
|
52
|
+
* component waiting on it.
|
|
53
|
+
*
|
|
54
|
+
* Worth its own predicate because it is the one failure that must NOT be reported, retried or
|
|
55
|
+
* shown: nobody is waiting for the answer. Distinguishing it from a real failure is what keeps
|
|
56
|
+
* a route change from filling the console with errors nobody caused.
|
|
57
|
+
*/
|
|
58
|
+
export function isAbortError(error: unknown): boolean {
|
|
59
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
60
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { create, type StoreApi, type UseBoundStore } from "zustand";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session FLAGS only.
|
|
5
|
+
*
|
|
6
|
+
* Who the person is — their plan, their staff bit, whether they are suspended — comes from
|
|
7
|
+
* `GET /auth/me` through react-query, never from here. Mirroring server state in a client store
|
|
8
|
+
* is how two sources of truth start disagreeing, and the one that is wrong is always the one on
|
|
9
|
+
* screen. Three repos wrote this same store; two of them wrote that same warning in a comment.
|
|
10
|
+
*
|
|
11
|
+
* `TUser` stays generic because it is the one part that differs: one product carries an
|
|
12
|
+
* anonymous-browsing flag, another does not. Keep it to what a GUARD needs — an id and an
|
|
13
|
+
* email is what all three donors had.
|
|
14
|
+
*/
|
|
15
|
+
export interface AuthState<TUser> {
|
|
16
|
+
user: TUser | null;
|
|
17
|
+
isAuthenticated: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* True until the first session lookup resolves. The guards must not bounce somebody to
|
|
20
|
+
* /login while we are still finding out whether they are signed in.
|
|
21
|
+
*/
|
|
22
|
+
isLoading: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Set (or clear) the signed-in user. This ENDS the loading state — knowing who they are is
|
|
25
|
+
* what the bootstrap was waiting for. Two of the three donors left `isLoading` alone here and
|
|
26
|
+
* relied on a separate `setLoading(false)`; forgetting that call leaves every guard spinning,
|
|
27
|
+
* so the safe default is to do it in one write.
|
|
28
|
+
*/
|
|
29
|
+
setUser: (user: TUser | null) => void;
|
|
30
|
+
setLoading: (isLoading: boolean) => void;
|
|
31
|
+
/** Sign-out: clears the user and ends the loading state in one write. */
|
|
32
|
+
clear: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create the store.
|
|
37
|
+
*
|
|
38
|
+
* The whole reason this is a factory and not a store: `isLoading` must start FALSE where there
|
|
39
|
+
* is no window, and only a factory can decide that at the call site rather than at import.
|
|
40
|
+
*
|
|
41
|
+
* A session bootstrap can only be in flight in a browser. The BUILD renders this app to files
|
|
42
|
+
* with no window at all, so `true` there is a wait that never ends — one donor shipped a route
|
|
43
|
+
* guard holding its loading screen forever, and prerendered a public page as 1,174 bytes of
|
|
44
|
+
* `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be
|
|
45
|
+
* found. With no browser there is no session and never will be, which is exactly the state a
|
|
46
|
+
* first-time visitor arrives in, so that is what the page should render.
|
|
47
|
+
*
|
|
48
|
+
* Only one of the three donors knew this. It is invariant 7.
|
|
49
|
+
*/
|
|
50
|
+
export function createAuthStore<TUser>(): UseBoundStore<StoreApi<AuthState<TUser>>> {
|
|
51
|
+
return create<AuthState<TUser>>((set) => ({
|
|
52
|
+
user: null,
|
|
53
|
+
isAuthenticated: false,
|
|
54
|
+
isLoading: typeof window !== "undefined",
|
|
55
|
+
setUser: (user) => set({ user, isAuthenticated: Boolean(user), isLoading: false }),
|
|
56
|
+
setLoading: (isLoading) => set({ isLoading }),
|
|
57
|
+
clear: () => set({ user: null, isAuthenticated: false, isLoading: false }),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isChunkLoadError, isPreloadHintFailure } from "./chunk-reload.ts";
|
|
3
|
+
|
|
4
|
+
// No browser gives a failed dynamic import a shared type, so this is matched by message and
|
|
5
|
+
// every phrasing has to be here. A miss is a white screen after a deploy — the exact failure
|
|
6
|
+
// this module exists to catch — and it only reproduces on a real deploy, so the test is the
|
|
7
|
+
// only place these strings get checked.
|
|
8
|
+
describe("isChunkLoadError", () => {
|
|
9
|
+
it.each([
|
|
10
|
+
["Chrome/Edge", "Failed to fetch dynamically imported module: https://x/assets/a.js"],
|
|
11
|
+
["Firefox", "error loading dynamically imported module"],
|
|
12
|
+
["Safari", "Importing a module script failed."],
|
|
13
|
+
["webpack", "Loading chunk 42 failed."],
|
|
14
|
+
// The one that actually fires on a static host: the missing .js is answered with the SPA
|
|
15
|
+
// fallback, so the browser refuses the HTML rather than reporting a 404.
|
|
16
|
+
[
|
|
17
|
+
"SPA fallback",
|
|
18
|
+
"Expected a JavaScript module script but the server responded with a MIME type of text/html",
|
|
19
|
+
],
|
|
20
|
+
])("matches %s", (_browser, message) => {
|
|
21
|
+
expect(isChunkLoadError(new Error(message))).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("matches a named ChunkLoadError whatever its message", () => {
|
|
25
|
+
const error = new Error("nothing recognisable");
|
|
26
|
+
error.name = "ChunkLoadError";
|
|
27
|
+
expect(isChunkLoadError(error)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not match an ordinary render error", () => {
|
|
31
|
+
expect(isChunkLoadError(new TypeError("Cannot read properties of undefined"))).toBe(false);
|
|
32
|
+
expect(isChunkLoadError(null)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// The distinction that matters: swallowing a real module failure resolves the import with
|
|
37
|
+
// `undefined`, React.lazy reads `.default` off nothing, and the developer gets a TypeError with
|
|
38
|
+
// only React frames in it — naming no chunk. Only the CSS hint is safe to swallow.
|
|
39
|
+
describe("isPreloadHintFailure", () => {
|
|
40
|
+
it("matches Vite's CSS preload hint and nothing else", () => {
|
|
41
|
+
expect(isPreloadHintFailure(new Error("Unable to preload CSS for /assets/a.css"))).toBe(true);
|
|
42
|
+
expect(isPreloadHintFailure(new Error("Failed to fetch dynamically imported module"))).toBe(
|
|
43
|
+
false,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surviving a deploy that lands while a tab is open.
|
|
3
|
+
*
|
|
4
|
+
* Every app on this stack is a Vite SPA with `lazy()` routes, deployed on push. When a build
|
|
5
|
+
* replaces the one a tab is running, the next navigation asks for a chunk whose hashed name no
|
|
6
|
+
* longer exists — and a static host answers a missing asset with the SPA fallback, so the
|
|
7
|
+
* import gets HTML where it expected a module. React's lazy boundary throws, and the person
|
|
8
|
+
* gets a white screen on a click that worked a minute ago.
|
|
9
|
+
*
|
|
10
|
+
* One repo in the fleet had all of this. The others had none of it, and none of them knew.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const RELOAD_FLAG_KEY = "frontkit:chunk-reload-at";
|
|
14
|
+
const RELOAD_WINDOW_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A lazy-chunk fetch failure — recoverable by reloading.
|
|
18
|
+
*
|
|
19
|
+
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is
|
|
20
|
+
* here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
|
|
21
|
+
* dynamically imported module", Safari "Importing a module script failed", webpack's
|
|
22
|
+
* ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
|
|
23
|
+
* which is the one that actually fires on a static host, because the missing `.js` is answered
|
|
24
|
+
* with `index.html`.
|
|
25
|
+
*
|
|
26
|
+
* A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
|
|
27
|
+
* handler can classify the same failure without importing a component.
|
|
28
|
+
*/
|
|
29
|
+
export function isChunkLoadError(error: unknown): boolean {
|
|
30
|
+
if (error instanceof Error && error.name === "ChunkLoadError") return true;
|
|
31
|
+
const message = (error instanceof Error ? error.message : String(error) || "").toLowerCase();
|
|
32
|
+
return (
|
|
33
|
+
message.includes("dynamically imported module") ||
|
|
34
|
+
message.includes("importing a module script failed") ||
|
|
35
|
+
message.includes("mime type") ||
|
|
36
|
+
message.includes("loading chunk")
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hard-reload once to pick up the fresh deploy. Returns false when the guard suppressed it.
|
|
42
|
+
*
|
|
43
|
+
* The guard is the part that matters. Reloading on a chunk error is obvious; reloading on a
|
|
44
|
+
* chunk error that the reload does not fix is an infinite loop with the user inside it. At most
|
|
45
|
+
* one reload per minute, so a genuinely missing chunk degrades to the error UI — which can at
|
|
46
|
+
* least say something — instead of flickering forever.
|
|
47
|
+
*
|
|
48
|
+
* `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament,
|
|
49
|
+
* and a stale flag in another tab must not suppress a reload this one needs. Wrapped because
|
|
50
|
+
* storage throws outright when site data is blocked, and a privacy setting must not be the
|
|
51
|
+
* reason somebody cannot recover.
|
|
52
|
+
*/
|
|
53
|
+
export function reloadOnceForChunkError(): boolean {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
try {
|
|
56
|
+
const last = Number(sessionStorage.getItem(RELOAD_FLAG_KEY) ?? 0);
|
|
57
|
+
if (now - last < RELOAD_WINDOW_MS) return false;
|
|
58
|
+
sessionStorage.setItem(RELOAD_FLAG_KEY, String(now));
|
|
59
|
+
} catch {
|
|
60
|
+
// Storage blocked — proceed with the reload rather than refusing to recover.
|
|
61
|
+
}
|
|
62
|
+
window.location.reload();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Vite's *hint* failure, which is not the same thing and must not be treated as one.
|
|
68
|
+
*
|
|
69
|
+
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
|
|
70
|
+
* When that link fails, the import itself still runs right after and usually succeeds — so this
|
|
71
|
+
* one is worth swallowing, where a failure of the import is the real thing
|
|
72
|
+
* {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
|
|
73
|
+
* for a stylesheet link; script hints never reject.
|
|
74
|
+
*/
|
|
75
|
+
export function isPreloadHintFailure(error: unknown): boolean {
|
|
76
|
+
return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Install the `vite:preloadError` listener. Call once, from the browser entry.
|
|
81
|
+
*
|
|
82
|
+
* Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
|
|
83
|
+
* deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
|
|
84
|
+
* `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
|
|
85
|
+
* The user gets the crash screen and the developer gets a TypeError with only React frames in
|
|
86
|
+
* it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
|
|
87
|
+
* runs. A real module failure is left to throw, where the error boundary can recognise it.
|
|
88
|
+
*/
|
|
89
|
+
export function installPreloadErrorHandler(): void {
|
|
90
|
+
window.addEventListener("vite:preloadError", (event) => {
|
|
91
|
+
const payload = (event as Event & { payload?: unknown }).payload;
|
|
92
|
+
if (isPreloadHintFailure(payload)) event.preventDefault();
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/cn.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compose class names, letting later Tailwind utilities win over earlier ones
|
|
6
|
+
* (`p-2 p-4` → `p-4`).
|
|
7
|
+
*
|
|
8
|
+
* That precedence is the whole point, and it is why this is not `clsx` alone: a caller's
|
|
9
|
+
* `className` has to be able to OVERRIDE a component's defaults rather than fight them at
|
|
10
|
+
* equal specificity, where the winner would be whichever rule Tailwind happened to emit last.
|
|
11
|
+
*
|
|
12
|
+
* Ten repos wrote this function. All ten wrote it identically.
|
|
13
|
+
*/
|
|
14
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
15
|
+
return twMerge(clsx(inputs));
|
|
16
|
+
}
|