@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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/dist/api-client.d.ts +139 -0
  4. package/dist/api-client.d.ts.map +1 -0
  5. package/dist/api-client.js +133 -0
  6. package/dist/api-client.js.map +1 -0
  7. package/dist/api-error.d.ts +45 -0
  8. package/dist/api-error.d.ts.map +1 -0
  9. package/dist/api-error.js +52 -0
  10. package/dist/api-error.js.map +1 -0
  11. package/dist/auth-store.d.ts +49 -0
  12. package/dist/auth-store.d.ts.map +1 -0
  13. package/dist/auth-store.js +27 -0
  14. package/dist/auth-store.js.map +1 -0
  15. package/dist/chunk-reload.d.ts +61 -0
  16. package/dist/chunk-reload.d.ts.map +1 -0
  17. package/dist/chunk-reload.js +92 -0
  18. package/dist/chunk-reload.js.map +1 -0
  19. package/dist/cn.d.ts +13 -0
  20. package/dist/cn.d.ts.map +1 -0
  21. package/dist/cn.js +16 -0
  22. package/dist/cn.js.map +1 -0
  23. package/dist/describe-error.d.ts +74 -0
  24. package/dist/describe-error.d.ts.map +1 -0
  25. package/dist/describe-error.js +70 -0
  26. package/dist/describe-error.js.map +1 -0
  27. package/dist/error-boundary.d.ts +61 -0
  28. package/dist/error-boundary.d.ts.map +1 -0
  29. package/dist/error-boundary.js +32 -0
  30. package/dist/error-boundary.js.map +1 -0
  31. package/dist/guards.d.ts +65 -0
  32. package/dist/guards.d.ts.map +1 -0
  33. package/dist/guards.js +52 -0
  34. package/dist/guards.js.map +1 -0
  35. package/dist/hydrate.d.ts +30 -0
  36. package/dist/hydrate.d.ts.map +1 -0
  37. package/dist/hydrate.js +25 -0
  38. package/dist/hydrate.js.map +1 -0
  39. package/dist/i18n.d.ts +70 -0
  40. package/dist/i18n.d.ts.map +1 -0
  41. package/dist/i18n.js +66 -0
  42. package/dist/i18n.js.map +1 -0
  43. package/dist/index.d.ts +12 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +31 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/prerender-contract.d.ts +30 -0
  48. package/dist/prerender-contract.d.ts.map +1 -0
  49. package/dist/prerender-contract.js +30 -0
  50. package/dist/prerender-contract.js.map +1 -0
  51. package/dist/query.d.ts +28 -0
  52. package/dist/query.d.ts.map +1 -0
  53. package/dist/query.js +51 -0
  54. package/dist/query.js.map +1 -0
  55. package/dist/sse.d.ts +64 -0
  56. package/dist/sse.d.ts.map +1 -0
  57. package/dist/sse.js +105 -0
  58. package/dist/sse.js.map +1 -0
  59. package/dist/states.d.ts +69 -0
  60. package/dist/states.d.ts.map +1 -0
  61. package/dist/states.js +2 -0
  62. package/dist/states.js.map +1 -0
  63. package/dist/ui/class-name.d.ts +16 -0
  64. package/dist/ui/class-name.d.ts.map +1 -0
  65. package/dist/ui/class-name.js +13 -0
  66. package/dist/ui/class-name.js.map +1 -0
  67. package/dist/ui/combobox.d.ts +81 -0
  68. package/dist/ui/combobox.d.ts.map +1 -0
  69. package/dist/ui/combobox.js +70 -0
  70. package/dist/ui/combobox.js.map +1 -0
  71. package/dist/ui/dialog.d.ts +39 -0
  72. package/dist/ui/dialog.d.ts.map +1 -0
  73. package/dist/ui/dialog.js +32 -0
  74. package/dist/ui/dialog.js.map +1 -0
  75. package/dist/ui/drawer.d.ts +63 -0
  76. package/dist/ui/drawer.d.ts.map +1 -0
  77. package/dist/ui/drawer.js +55 -0
  78. package/dist/ui/drawer.js.map +1 -0
  79. package/dist/ui/glyphs.d.ts +4 -0
  80. package/dist/ui/glyphs.d.ts.map +1 -0
  81. package/dist/ui/glyphs.js +20 -0
  82. package/dist/ui/glyphs.js.map +1 -0
  83. package/dist/ui/index.d.ts +9 -0
  84. package/dist/ui/index.d.ts.map +1 -0
  85. package/dist/ui/index.js +48 -0
  86. package/dist/ui/index.js.map +1 -0
  87. package/dist/ui/input.d.ts +45 -0
  88. package/dist/ui/input.d.ts.map +1 -0
  89. package/dist/ui/input.js +108 -0
  90. package/dist/ui/input.js.map +1 -0
  91. package/dist/ui/menu.d.ts +70 -0
  92. package/dist/ui/menu.d.ts.map +1 -0
  93. package/dist/ui/menu.js +85 -0
  94. package/dist/ui/menu.js.map +1 -0
  95. package/dist/ui/select.d.ts +69 -0
  96. package/dist/ui/select.d.ts.map +1 -0
  97. package/dist/ui/select.js +48 -0
  98. package/dist/ui/select.js.map +1 -0
  99. package/dist/ui/tabs.d.ts +52 -0
  100. package/dist/ui/tabs.d.ts.map +1 -0
  101. package/dist/ui/tabs.js +62 -0
  102. package/dist/ui/tabs.js.map +1 -0
  103. package/package.json +124 -0
  104. package/src/api-client.test.ts +256 -0
  105. package/src/api-client.ts +305 -0
  106. package/src/api-error.ts +60 -0
  107. package/src/auth-store.ts +59 -0
  108. package/src/chunk-reload.test.ts +46 -0
  109. package/src/chunk-reload.ts +94 -0
  110. package/src/cn.ts +16 -0
  111. package/src/describe-error.test.ts +102 -0
  112. package/src/describe-error.ts +140 -0
  113. package/src/error-boundary.tsx +88 -0
  114. package/src/guards.tsx +99 -0
  115. package/src/hydrate.ts +36 -0
  116. package/src/i18n.test.ts +68 -0
  117. package/src/i18n.ts +101 -0
  118. package/src/index.ts +63 -0
  119. package/src/prerender-contract.ts +31 -0
  120. package/src/query.test.ts +54 -0
  121. package/src/query.ts +79 -0
  122. package/src/sse.test.ts +50 -0
  123. package/src/sse.ts +117 -0
  124. package/src/states.ts +71 -0
  125. package/src/ui/class-name.ts +23 -0
  126. package/src/ui/combobox.test.ts +32 -0
  127. package/src/ui/combobox.tsx +242 -0
  128. package/src/ui/dialog.tsx +87 -0
  129. package/src/ui/drawer.tsx +161 -0
  130. package/src/ui/focus.test.ts +82 -0
  131. package/src/ui/glyphs.tsx +35 -0
  132. package/src/ui/index.ts +145 -0
  133. package/src/ui/input.test.ts +22 -0
  134. package/src/ui/input.tsx +181 -0
  135. package/src/ui/menu.test.ts +73 -0
  136. package/src/ui/menu.tsx +186 -0
  137. package/src/ui/select.tsx +191 -0
  138. package/src/ui/tabs.tsx +95 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # @gusnips/react
2
+
3
+ The layer under a Vite + React SPA: the fetch client, the auth store, the route guards, the error
4
+ boundary, the query rules. No styling, no brand, nothing that decides how your app looks.
5
+
6
+ ```bash
7
+ bun add @gusnips/react
8
+ ```
9
+
10
+ ```ts
11
+ const user = await api.get<User>("/me");
12
+ ```
13
+
14
+ That is the whole call. `user` is a `User` — not a `Response`, not `res.json()`, not a null
15
+ check. The client attached the token, unwrapped the envelope, and threw a typed `ApiError`
16
+ carrying the code and the request id if the server refused.
17
+
18
+ You build `api` once, wherever you keep that sort of thing:
19
+
20
+ ```ts
21
+ import { createApiClient } from "@gusnips/react";
22
+
23
+ const api = createApiClient({
24
+ baseUrl: "https://api.example.com",
25
+ session: mySession,
26
+ onSessionDead: () => window.location.replace("/sign-in"),
27
+ });
28
+ ```
29
+
30
+ ## The client knows three things people learn the hard way
31
+
32
+ **Six queries firing at once send one refresh, not six.** The auth server rotates the refresh
33
+ token when you use it, so the losers of that race each invalidate the winner — and the person is
34
+ signed out in the middle of a load that was working. Three separate codebases arrived at
35
+ single-flight refresh independently. This is that.
36
+
37
+ **A refresh that never reached the server is not a "no".** Dropping a packet tells you nothing
38
+ about whether a session is good. So `refresh()` answers `{ token, reachedAuth }`, and only a real
39
+ refusal signs anyone out. Before that split, a Wi-Fi blip logged people out mid-load.
40
+
41
+ **A sign-out has a fail-safe timer.** Awaiting `signOut()` before redirecting covers a rejection,
42
+ not a hang — and a hang leaves someone signed out in name only: every request 401ing, nothing
43
+ left that could redirect it.
44
+
45
+ There is also a deadline on every request, which none of the codebases this came from had. A
46
+ request with no timeout is a spinner with no end.
47
+
48
+ ## Never retry what waiting cannot fix
49
+
50
+ ```ts
51
+ import { queryDefaults } from "@gusnips/react";
52
+
53
+ new QueryClient({
54
+ defaultOptions: queryDefaults({ durableLimitCodes: ["QUOTA_EXCEEDED", "PAYMENT_REQUIRED"] }),
55
+ });
56
+ ```
57
+
58
+ Retries 408, a transient 429, 5xx, and no-response-at-all. Nothing else — every other 4xx is an
59
+ answer, and a second attempt just says it again.
60
+
61
+ `durableLimitCodes` is the part a status code cannot tell you. A spent monthly quota and a burst
62
+ limit both arrive as 429, but one clears by waiting and the other clears by buying. Retrying the
63
+ second burns another request against the limiter to hear the same thing three times, so you name
64
+ those codes and they are never retried.
65
+
66
+ ## Survives your deploys
67
+
68
+ An app with `lazy()` routes serves chunks by hashed filename. Deploy while someone has a tab
69
+ open, and their next click asks for a file that no longer exists — a white screen on a button
70
+ that worked a minute ago.
71
+
72
+ Two halves. In your browser entry:
73
+
74
+ ```ts
75
+ import { installPreloadErrorHandler } from "@gusnips/react";
76
+
77
+ installPreloadErrorHandler();
78
+ ```
79
+
80
+ And in your error boundary:
81
+
82
+ ```ts
83
+ if (isChunkLoadError(error) && reloadOnceForChunkError()) return null;
84
+ ```
85
+
86
+ `reloadOnceForChunkError` reloads at most once a minute, because reloading on a chunk error the
87
+ reload does not fix is an infinite loop with a person inside it. Past that, a genuinely missing
88
+ chunk degrades to your error screen, which can at least say something.
89
+
90
+ The handler is the other half, and it is narrower than it looks: it swallows Vite's CSS _preload
91
+ hint_ failure and nothing else. A hint that fails is harmless — the import runs straight after
92
+ and usually works. Taking Vite's `preventDefault()` for every preload error instead resolves the
93
+ dynamic import with `undefined`, `React.lazy` reads `.default` off nothing, and you get a crash
94
+ screen plus a TypeError with only React frames in it, naming no chunk.
95
+
96
+ ## Guards that can tell "no" from "I don't know"
97
+
98
+ ```tsx
99
+ import { createRequireProfile } from "@gusnips/react/guards";
100
+
101
+ const requireProfile = createRequireProfile(useMe, { loading: <Spinner /> });
102
+
103
+ const RequireStaff = requireProfile(
104
+ (me) => me.isStaff,
105
+ <NotFound />,
106
+ (error) => <ErrorScreen error={error} />,
107
+ );
108
+ ```
109
+
110
+ `onDenied` and `onError` are separate because `!me?.isStaff` reads a 500 as "not staff". An
111
+ operator arriving while `/auth/me` is down gets told the page does not exist: the wrong cause, no
112
+ retry, and no request id to quote. A guard has three answers — wait, fail, refuse — and only a
113
+ real `false` reaches the refusal.
114
+
115
+ ## Never dead-end anyone
116
+
117
+ ```ts
118
+ import type { ErrorStateProps } from "@gusnips/react";
119
+ ```
120
+
121
+ No component ships — seven codebases have an `ErrorState.tsx` and they overlap 9–37%, because one
122
+ draws a tinted icon, one an illustration, one a mascot. What they share is the prop shape, and
123
+ the prop shape _is_ the rule: `problem`, `cause`, `fix`, and `action`. **`fix` and `action` are
124
+ required.** A required prop is the only version of "always offer a way out" that a caller in a
125
+ hurry cannot skip.
126
+
127
+ `createErrorDescriber` turns a thrown `ApiError` into the `cause` and `fix` to put in it.
128
+
129
+ ## Also here
130
+
131
+ `hydrateOrMount` for prerendered pages, `ErrorBoundary`, an SSE reader split into a platform-free
132
+ parser and a stream wrapper, `i18nInitOptions`, and `cn`.
133
+
134
+ `createAuthStore` is at `@gusnips/react/store` — a zustand store whose `isLoading` starts `false`
135
+ where there is no window. A session bootstrap can only be in flight in a browser, and `true`
136
+ during a build is a wait that never ends: it once shipped a spinner as the indexable body of a
137
+ page whose whole job was to be found.
138
+
139
+ ## The Base UI wrappers
140
+
141
+ ```ts
142
+ import { Dialog, Drawer, Select, Combobox, Menu, Tabs, Input } from "@gusnips/react/ui";
143
+ ```
144
+
145
+ Seven, behind a subpath, so an app on Radix or on nothing never resolves `@base-ui/react`.
146
+
147
+ They are seven because the audit that produced them started with 27 and the hypothesis was wrong:
148
+ no wrapper added scroll lock, focus trap, ESC, outside-dismiss, focus return, roving focus or
149
+ typeahead. Base UI already does all of that, and a wrapper "adding" them is a second
150
+ implementation of something that works. Twenty were dropped.
151
+
152
+ What the remaining seven buy is composition you cannot skip — no caller can ship a scrimless
153
+ dialog — required a11y props expressed as types, and a handful of facts that each cost somebody
154
+ an afternoon. z-index goes on the Viewport, not the Popup, because `position: fixed` makes a
155
+ stacking context. A drawer _ties_ with dialog rather than beating it, or a modal opened from
156
+ inside a drawer never paints. A wrapper earns its place by knowing something, not by styling
157
+ something.
158
+
159
+ ## Subpaths, and what each one costs you
160
+
161
+ `@gusnips/react` itself needs `react`, `react-dom` and nothing else. Anything that needs more
162
+ lives behind a subpath, so you install a dependency only if you import the thing that uses it:
163
+
164
+ | Import from | What is in it | What you must have |
165
+ | ------------------------- | ------------------- | ------------------ |
166
+ | `@gusnips/react` | the client and rest | react, react-dom |
167
+ | `@gusnips/react/store` | `createAuthStore` | zustand |
168
+ | `@gusnips/react/guards` | the route guards | react-router-dom |
169
+ | `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
170
+ | `@gusnips/react/contract` | two prerender names | nothing |
171
+
172
+ The rule behind that table: **a peer marked optional must not be reachable from the main entry
173
+ point.** An optional peer the barrel imports anyway is not optional — it is a required one whose
174
+ error moved from install time to your first build, which is the worse of the two places to learn
175
+ about it.
176
+
177
+ `@tanstack/react-query`, `i18next` and `react-i18next` are optional and stay in the main entry,
178
+ because only their types are used and types erase.
179
+
180
+ MIT · part of [frontkit](https://github.com/gusnips/frontkit)
@@ -0,0 +1,139 @@
1
+ import { type ApiSuccess } from "@gusnips/http";
2
+ import { ApiError } from "./api-error.ts";
3
+ /**
4
+ * The app's one door to the API. Nothing else should call `fetch`.
5
+ *
6
+ * Every route answers the same envelope, so unwrapping and error-shaping belong here once
7
+ * rather than in every hook. Three donors built this independently and all three arrived at
8
+ * single-flight token refresh — see {@link SessionAdapter.refresh} for the reason, which is
9
+ * the sharpest thing in this file.
10
+ *
11
+ * Four things deliberately did NOT come across from the donors, because they belong to a
12
+ * product and not to a client:
13
+ *
14
+ * - **Toasts.** Whether a failure is spoken aloud is a design decision that differs per surface.
15
+ * Throw, and let the caller decide.
16
+ * - **In-flight GET deduplication.** react-query already dedups by key, so the client's copy is
17
+ * redundant — and one donor's version returned a `clone()` of a body already being consumed,
18
+ * which is a race with no symptom until it has one.
19
+ * - **Service-status tracking and impersonation.** One donor's, and firmly its own.
20
+ * - **Analytics correlation headers.** Injected through {@link ApiClientOptions.headers}.
21
+ */
22
+ /** What a refresh attempt actually learned. */
23
+ export interface RefreshResult {
24
+ token: string | null;
25
+ /**
26
+ * False when the refresh never got an ANSWER out of the auth server — a dropped connection,
27
+ * a 5xx, a captive portal.
28
+ *
29
+ * This flag is invariant 3, and only one of three donors had it. Losing a packet says nothing
30
+ * about whether a session is still good, so a client that collapses "auth said no" into
31
+ * "auth did not answer" signs people out over a Wi-Fi blip and costs them their place for
32
+ * nothing. Auth libraries usually name this: Supabase throws `AuthRetryableFetchError`, so
33
+ * an adapter answers `reachedAuth: !(error instanceof AuthRetryableFetchError)`.
34
+ */
35
+ reachedAuth: boolean;
36
+ }
37
+ /**
38
+ * The seam onto whatever holds the session. Supabase in every donor so far, but nothing here
39
+ * knows that — an adapter is four lines and keeps the auth vendor out of this package.
40
+ */
41
+ export interface SessionAdapter {
42
+ getToken(): Promise<string | null>;
43
+ refresh(): Promise<RefreshResult>;
44
+ signOut(): Promise<void>;
45
+ }
46
+ export interface ApiClientOptions {
47
+ /** Origin + prefix, e.g. `https://api.example.com/v1`. No trailing slash. */
48
+ baseUrl: string;
49
+ session: SessionAdapter;
50
+ /**
51
+ * Extra headers, computed per request.
52
+ *
53
+ * A function, not an object, and that matters: one donor read
54
+ * `document.documentElement.lang` inline in its request builder, which crashes a prerender
55
+ * outright. Anything that touches the DOM, the current locale or the clock goes in here,
56
+ * where it runs only when a request is actually being sent.
57
+ */
58
+ headers?: () => Record<string, string>;
59
+ /**
60
+ * The session is gone and cannot be renewed — send them to sign in.
61
+ *
62
+ * The client does not know the route, and it deliberately still THROWS after calling this:
63
+ * `location.replace()` does not stop the current frame, so a caller's own `onError` must
64
+ * still run or a half-finished screen keeps rendering against data that will never arrive.
65
+ * The error it throws carries `expected: true`.
66
+ */
67
+ onSessionDead: () => void;
68
+ /**
69
+ * Every failed response, seen once, just before it is thrown.
70
+ *
71
+ * For a reaction that belongs to the whole app rather than to one call site. The donor's case
72
+ * is the sharp one: a mid-session account suspension 403s every authed route except
73
+ * `GET /auth/me`, so the moment one arrives the app has to refresh `me` and route to the
74
+ * screen that explains it — otherwise every query on the page fails at once and the shell
75
+ * half-renders behind an error storm until `me` goes stale on its own.
76
+ *
77
+ * It cannot live at a call site, because the point is that it fires from whichever call
78
+ * happened to be first. It went in the donor's client directly, which made the client import
79
+ * its query cache and its query keys — a cycle that this hook removes.
80
+ *
81
+ * Observation only: the error is thrown either way, and throwing from here would replace a
82
+ * real API failure with whatever the listener hit.
83
+ */
84
+ onError?: (error: ApiError) => void;
85
+ /**
86
+ * Abort a request that has not answered. Default 30s.
87
+ *
88
+ * Neither donor bounded its authenticated requests at all — both bounded only their keyless
89
+ * clients — so a stalled connection was a spinner with no end. providerkit learned the same
90
+ * lesson about streams: a request with no deadline is a bug that only shows up on a bad
91
+ * network, which is exactly when nobody can reproduce it.
92
+ */
93
+ timeoutMs?: number;
94
+ /** How many times to refresh-and-retry a 401. Default 2. */
95
+ maxRefreshAttempts?: number;
96
+ /** Pause between refresh attempts. Default 500ms. */
97
+ refreshRetryDelayMs?: number;
98
+ /**
99
+ * How long to wait for `signOut()` before redirecting anyway. Default 3s.
100
+ *
101
+ * A fail-safe, and one donor added it after the failure it prevents: awaiting `signOut()`
102
+ * covers a rejection but not a HANG, and a hang leaves the tab signed out in name only —
103
+ * still on the page, every request 401ing, nothing left that could redirect it.
104
+ */
105
+ signOutTimeoutMs?: number;
106
+ /** Header carrying the server's request id, echoed onto {@link ApiError.requestId}. */
107
+ requestIdHeader?: string;
108
+ }
109
+ export interface RequestOptions extends Omit<RequestInit, "headers"> {
110
+ headers?: Record<string, string>;
111
+ /** Override the client's default timeout for this call. `null` disables it (streams). */
112
+ timeoutMs?: number | null;
113
+ }
114
+ export interface ApiClient {
115
+ /** The raw `Response`, past auth and error handling. For blobs, streams and downloads. */
116
+ request(path: string, options?: RequestOptions): Promise<Response>;
117
+ /** `data` out of the success envelope. */
118
+ get<T>(path: string, options?: RequestOptions): Promise<T>;
119
+ post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
120
+ put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
121
+ patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
122
+ /**
123
+ * DELETE, discarding whatever comes back. Returns `void` because 204 is the usual answer and
124
+ * a 204 has NO BODY — `res.json()` on one throws `Unexpected end of JSON input`, which reads
125
+ * like a malformed response and is really just a success nobody was allowed to parse.
126
+ *
127
+ * Split from {@link ApiClient.delJson} rather than guessing per response, because the guess is
128
+ * the part that hides a bug: a route that was supposed to answer with data and returned
129
+ * nothing should fail loudly here, not hand back a silent `undefined` that surfaces three
130
+ * layers away. The donor that hit this had written exactly these two functions.
131
+ */
132
+ del(path: string, options?: RequestOptions): Promise<void>;
133
+ /** The DELETE that answers with something worth reading — a queue entry it handed back. */
134
+ delJson<T>(path: string, options?: RequestOptions): Promise<T>;
135
+ /** The whole envelope, for a list route whose counts live in `meta`. */
136
+ page<T, M>(path: string, options?: RequestOptions): Promise<ApiSuccess<T, M>>;
137
+ }
138
+ export declare function createApiClient({ baseUrl, session, headers: extraHeaders, onSessionDead, onError, timeoutMs, maxRefreshAttempts, refreshRetryDelayMs, signOutTimeoutMs, requestIdHeader, }: ApiClientOptions): ApiClient;
139
+ //# sourceMappingURL=api-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,133 @@
1
+ import { isApiError } from "@gusnips/http";
2
+ import { ApiError } from "./api-error.js";
3
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4
+ export function createApiClient({ baseUrl, session, headers: extraHeaders, onSessionDead, onError, timeoutMs = 30_000, maxRefreshAttempts = 2, refreshRetryDelayMs = 500, signOutTimeoutMs = 3_000, requestIdHeader = "x-request-id", }) {
5
+ /**
6
+ * Single-flight refresh.
7
+ *
8
+ * A page that fires six queries at once must not send six refreshes. The auth server rotates
9
+ * the refresh token on use, so the losers of that race each invalidate the winner — and the
10
+ * person is signed out in the middle of a load that was working. All three donors found this
11
+ * the hard way and all three fixed it the same way.
12
+ */
13
+ let refreshing = null;
14
+ function refresh() {
15
+ refreshing ??= session
16
+ .refresh()
17
+ .catch(() => ({ token: null, reachedAuth: false }))
18
+ .finally(() => {
19
+ refreshing = null;
20
+ });
21
+ return refreshing;
22
+ }
23
+ /** Latches, so whichever request gets here first owns the sign-out and the rest are no-ops. */
24
+ let signingOut = false;
25
+ function sessionDead() {
26
+ if (!signingOut) {
27
+ signingOut = true;
28
+ let done = false;
29
+ const go = () => {
30
+ if (done)
31
+ return;
32
+ done = true;
33
+ onSessionDead();
34
+ };
35
+ void session
36
+ .signOut()
37
+ .catch(() => { })
38
+ .finally(go);
39
+ setTimeout(go, signOutTimeoutMs);
40
+ }
41
+ // Thrown even though the redirect is under way: see `onSessionDead`.
42
+ throw new ApiError(401, { code: "UNAUTHORIZED", message: "Session expired" }, { expected: true });
43
+ }
44
+ function buildHeaders(token, extra) {
45
+ const headers = new Headers({ ...extraHeaders?.(), ...extra });
46
+ if (token)
47
+ headers.set("Authorization", `Bearer ${token}`);
48
+ return headers;
49
+ }
50
+ async function send(path, token, { headers, timeoutMs: perCall, signal, ...init }) {
51
+ const budget = perCall === undefined ? timeoutMs : perCall;
52
+ if (budget === null)
53
+ return fetch(`${baseUrl}${path}`, { ...init, signal, headers: buildHeaders(token, headers) });
54
+ // `AbortSignal.any` rather than a listener: it also catches the already-aborted race, where
55
+ // a caller's signal fired before we ever attached. It keeps the caller's abort and our
56
+ // timeout distinguishable at the source, which is what lets `isAbortError` stay honest.
57
+ const timeout = AbortSignal.timeout(budget);
58
+ return fetch(`${baseUrl}${path}`, {
59
+ ...init,
60
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
61
+ headers: buildHeaders(token, headers),
62
+ });
63
+ }
64
+ async function request(path, options = {}) {
65
+ let res = await send(path, await session.getToken(), options);
66
+ if (res.status === 401) {
67
+ // Only a refresh that actually REACHED auth proves the session is gone. Anything else is
68
+ // a network problem, and the 401 falls through as an ordinary error — the next request
69
+ // refreshes cleanly once the connection is back. Invariant 3.
70
+ let answered = false;
71
+ for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
72
+ if (attempt > 0)
73
+ await sleep(refreshRetryDelayMs);
74
+ const result = await refresh();
75
+ answered = result.reachedAuth;
76
+ if (!result.token)
77
+ continue;
78
+ res = await send(path, result.token, options);
79
+ if (res.status !== 401)
80
+ break;
81
+ }
82
+ if (res.status === 401 && answered)
83
+ sessionDead();
84
+ }
85
+ if (!res.ok) {
86
+ const body = await res.json().catch(() => null);
87
+ const error = new ApiError(res.status, isApiError(body) ? body.error : null, {
88
+ requestId: res.headers.get(requestIdHeader) ?? undefined,
89
+ });
90
+ // A listener that throws must not become the error the caller sees: the API failure is
91
+ // the real news, and swallowing it for a bug in a side effect would send everyone
92
+ // debugging the wrong thing.
93
+ try {
94
+ onError?.(error);
95
+ }
96
+ catch {
97
+ // ignored on purpose — see above
98
+ }
99
+ throw error;
100
+ }
101
+ return res;
102
+ }
103
+ /**
104
+ * The success envelope. `request` has already thrown on anything that is not one, so what is
105
+ * left is `{data, meta?}` by contract — this is the single place that assertion is made.
106
+ */
107
+ async function envelope(res) {
108
+ return (await res.json());
109
+ }
110
+ const json = (body, options = {}) => body === undefined
111
+ ? options
112
+ : {
113
+ ...options,
114
+ body: JSON.stringify(body),
115
+ headers: { "Content-Type": "application/json", ...options.headers },
116
+ };
117
+ async function data(path, options) {
118
+ return (await envelope(await request(path, options))).data;
119
+ }
120
+ return {
121
+ request,
122
+ get: (path, options = {}) => data(path, { ...options, method: "GET" }),
123
+ post: (path, body, options = {}) => data(path, json(body, { ...options, method: "POST" })),
124
+ put: (path, body, options = {}) => data(path, json(body, { ...options, method: "PUT" })),
125
+ patch: (path, body, options = {}) => data(path, json(body, { ...options, method: "PATCH" })),
126
+ del: async (path, options = {}) => {
127
+ await request(path, { ...options, method: "DELETE" });
128
+ },
129
+ delJson: (path, options = {}) => data(path, { ...options, method: "DELETE" }),
130
+ page: async (path, options = {}) => envelope(await request(path, { ...options, method: "GET" })),
131
+ };
132
+ }
133
+ //# sourceMappingURL=api-client.js.map
@@ -0,0 +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,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AA+I1C,MAAM,KAAK,GAAG,CAAC,EAAU,EAAiB,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;AAEnF,MAAM,UAAU,eAAe,CAAC,EAC9B,OAAO,EACP,OAAO,EACP,OAAO,EAAE,YAAY,EACrB,aAAa,EACb,OAAO,EACP,SAAS,GAAG,MAAM,EAClB,kBAAkB,GAAG,CAAC,EACtB,mBAAmB,GAAG,GAAG,EACzB,gBAAgB,GAAG,KAAK,EACxB,eAAe,GAAG,cAAc,GACf;IACjB;;;;;;;OAOG;IACH,IAAI,UAAU,GAAkC,IAAI,CAAC;IACrD,SAAS,OAAO;QACd,UAAU,KAAK,OAAO;aACnB,OAAO,EAAE;aACT,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC,CAAC;aAClD,OAAO,CAAC,GAAG,EAAE;YACZ,UAAU,GAAG,IAAI,CAAC;QACpB,CAAC,CAAC,CAAC;QACL,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,+FAA+F;IAC/F,IAAI,UAAU,GAAG,KAAK,CAAC;IACvB,SAAS,WAAW;QAClB,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,UAAU,GAAG,IAAI,CAAC;YAClB,IAAI,IAAI,GAAG,KAAK,CAAC;YACjB,MAAM,EAAE,GAAG,GAAS,EAAE;gBACpB,IAAI,IAAI;oBAAE,OAAO;gBACjB,IAAI,GAAG,IAAI,CAAC;gBACZ,aAAa,EAAE,CAAC;YAClB,CAAC,CAAC;YACF,KAAK,OAAO;iBACT,OAAO,EAAE;iBACT,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC;iBACf,OAAO,CAAC,EAAE,CAAC,CAAC;YACf,UAAU,CAAC,EAAE,EAAE,gBAAgB,CAAC,CAAC;QACnC,CAAC;QACD,qEAAqE;QACrE,MAAM,IAAI,QAAQ,CAChB,GAAG,EACH,EAAE,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,iBAAiB,EAAE,EACpD,EAAE,QAAQ,EAAE,IAAI,EAAE,CACnB,CAAC;IACJ,CAAC;IAED,SAAS,YAAY,CAAC,KAAoB,EAAE,KAA8B;QACxE,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,EAAE,GAAG,YAAY,EAAE,EAAE,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC;QAC/D,IAAI,KAAK;YAAE,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,KAAK,EAAE,CAAC,CAAC;QAC3D,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,KAAK,UAAU,IAAI,CACjB,IAAY,EACZ,KAAoB,EACpB,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAkB;QAEhE,MAAM,MAAM,GAAG,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;QAC3D,IAAI,MAAM,KAAK,IAAI;YACjB,OAAO,KAAK,CAAC,GAAG,OAAO,GAAG,IAAI,EAAE,EAAE,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,CAAC,KAAK,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;QAEhG,4FAA4F;QAC5F,uFAAuF;QACvF,wFAAwF;QACxF,MAAM,OAAO,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC5C,OAAO,KAAK,CAAC,GAAG,OAAO,GAAG,IAAI,EAAE,EAAE;YAChC,GAAG,IAAI;YACP,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO;YAC7D,OAAO,EAAE,YAAY,CAAC,KAAK,EAAE,OAAO,CAAC;SACtC,CAAC,CAAC;IACL,CAAC;IAED,KAAK,UAAU,OAAO,CAAC,IAAY,EAAE,UAA0B,EAAE;QAC/D,IAAI,GAAG,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,QAAQ,EAAE,EAAE,OAAO,CAAC,CAAC;QAE9D,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACvB,yFAAyF;YACzF,uFAAuF;YACvF,8DAA8D;YAC9D,IAAI,QAAQ,GAAG,KAAK,CAAC;YACrB,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,GAAG,kBAAkB,EAAE,OAAO,EAAE,EAAE,CAAC;gBAC9D,IAAI,OAAO,GAAG,CAAC;oBAAE,MAAM,KAAK,CAAC,mBAAmB,CAAC,CAAC;gBAClD,MAAM,MAAM,GAAG,MAAM,OAAO,EAAE,CAAC;gBAC/B,QAAQ,GAAG,MAAM,CAAC,WAAW,CAAC;gBAC9B,IAAI,CAAC,MAAM,CAAC,KAAK;oBAAE,SAAS;gBAC5B,GAAG,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;gBAC9C,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG;oBAAE,MAAM;YAChC,CAAC;YACD,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,IAAI,QAAQ;gBAAE,WAAW,EAAE,CAAC;QACpD,CAAC;QAED,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,MAAM,IAAI,GAAY,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;YACzD,MAAM,KAAK,GAAG,IAAI,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE;gBAC3E,SAAS,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,IAAI,SAAS;aACzD,CAAC,CAAC;YACH,uFAAuF;YACvF,kFAAkF;YAClF,6BAA6B;YAC7B,IAAI,CAAC;gBACH,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;YACnB,CAAC;YAAC,MAAM,CAAC;gBACP,iCAAiC;YACnC,CAAC;YACD,MAAM,KAAK,CAAC;QACd,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED;;;OAGG;IACH,KAAK,UAAU,QAAQ,CAAO,GAAa;QACzC,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAqB,CAAC;IAChD,CAAC;IAED,MAAM,IAAI,GAAG,CAAC,IAAa,EAAE,UAA0B,EAAE,EAAkB,EAAE,CAC3E,IAAI,KAAK,SAAS;QAChB,CAAC,CAAC,OAAO;QACT,CAAC,CAAC;YACE,GAAG,OAAO;YACV,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;YAC1B,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE;SACpE,CAAC;IAER,KAAK,UAAU,IAAI,CAAI,IAAY,EAAE,OAAuB;QAC1D,OAAO,CAAC,MAAM,QAAQ,CAAW,MAAM,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACvE,CAAC;IAED,OAAO;QACL,OAAO;QACP,GAAG,EAAE,CAAI,IAAY,EAAE,UAA0B,EAAE,EAAE,EAAE,CACrD,IAAI,CAAI,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;QAC9C,IAAI,EAAE,CAAI,IAAY,EAAE,IAAc,EAAE,UAA0B,EAAE,EAAE,EAAE,CACtE,IAAI,CAAI,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;QAC3D,GAAG,EAAE,CAAI,IAAY,EAAE,IAAc,EAAE,UAA0B,EAAE,EAAE,EAAE,CACrE,IAAI,CAAI,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;QAC1D,KAAK,EAAE,CAAI,IAAY,EAAE,IAAc,EAAE,UAA0B,EAAE,EAAE,EAAE,CACvE,IAAI,CAAI,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;QAC5D,GAAG,EAAE,KAAK,EAAE,IAAY,EAAE,UAA0B,EAAE,EAAE,EAAE;YACxD,MAAM,OAAO,CAAC,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC;QACxD,CAAC;QACD,OAAO,EAAE,CAAI,IAAY,EAAE,UAA0B,EAAE,EAAE,EAAE,CACzD,IAAI,CAAI,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;QACjD,IAAI,EAAE,KAAK,EAAQ,IAAY,EAAE,UAA0B,EAAE,EAAE,EAAE,CAC/D,QAAQ,CAAO,MAAM,OAAO,CAAC,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;KACrE,CAAC;AACJ,CAAC"}
@@ -0,0 +1,45 @@
1
+ import type { ApiError as ApiErrorBody } from "@gusnips/http";
2
+ /**
3
+ * A refusal, with the whole envelope intact.
4
+ *
5
+ * `code` is what the UI switches on, `messageKey` is what gets localized, and `details` carries
6
+ * the parts that make a refusal actionable — the `resetAt` on a 429, the plan that lifts a 402.
7
+ * Keeping all of it means a caller never has to re-parse a response body that has already been
8
+ * read once.
9
+ *
10
+ * A real `class` and not an interface with a factory: one donor used the interface shape, which
11
+ * works until something needs `instanceof` across a module boundary or wants a subclass.
12
+ */
13
+ export declare class ApiError extends Error {
14
+ readonly status: number;
15
+ readonly code: string | undefined;
16
+ readonly messageKey: string | undefined;
17
+ readonly params: Record<string, string | number> | undefined;
18
+ readonly details: unknown;
19
+ readonly requestId: string | undefined;
20
+ /**
21
+ * The client raised this itself to stop a caller while it was already handling the
22
+ * situation — today, the 401 thrown after a dead session, where the person is already on
23
+ * their way to the sign-in page.
24
+ *
25
+ * It is control flow, not a fault. Exception reporting should skip it, and an error surface
26
+ * should not ask somebody to retry something that is not broken. One donor had this and it is
27
+ * the difference between a clean sign-out and a crash report every time a session expires.
28
+ */
29
+ readonly expected: boolean;
30
+ constructor(status: number, body: ApiErrorBody["error"] | null, opts?: {
31
+ requestId?: string;
32
+ expected?: boolean;
33
+ message?: string;
34
+ });
35
+ }
36
+ /**
37
+ * An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
38
+ * component waiting on it.
39
+ *
40
+ * Worth its own predicate because it is the one failure that must NOT be reported, retried or
41
+ * shown: nobody is waiting for the answer. Distinguishing it from a real failure is what keeps
42
+ * a route change from filling the console with errors nobody caused.
43
+ */
44
+ export declare function isAbortError(error: unknown): boolean;
45
+ //# sourceMappingURL=api-error.d.ts.map
@@ -0,0 +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;IAEvC;;;;;;;;OAQG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;gBAGzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,IAAI,EAClC,IAAI,GAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO;CAY1E;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEpD"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A refusal, with the whole envelope intact.
3
+ *
4
+ * `code` is what the UI switches on, `messageKey` is what gets localized, and `details` carries
5
+ * the parts that make a refusal actionable — the `resetAt` on a 429, the plan that lifts a 402.
6
+ * Keeping all of it means a caller never has to re-parse a response body that has already been
7
+ * read once.
8
+ *
9
+ * A real `class` and not an interface with a factory: one donor used the interface shape, which
10
+ * works until something needs `instanceof` across a module boundary or wants a subclass.
11
+ */
12
+ export class ApiError extends Error {
13
+ status;
14
+ code;
15
+ messageKey;
16
+ params;
17
+ details;
18
+ requestId;
19
+ /**
20
+ * The client raised this itself to stop a caller while it was already handling the
21
+ * situation — today, the 401 thrown after a dead session, where the person is already on
22
+ * their way to the sign-in page.
23
+ *
24
+ * It is control flow, not a fault. Exception reporting should skip it, and an error surface
25
+ * should not ask somebody to retry something that is not broken. One donor had this and it is
26
+ * the difference between a clean sign-out and a crash report every time a session expires.
27
+ */
28
+ expected;
29
+ constructor(status, body, opts = {}) {
30
+ super(opts.message ?? body?.message ?? `Request failed (${status})`);
31
+ this.name = "ApiError";
32
+ this.status = status;
33
+ this.code = body?.code;
34
+ this.messageKey = body?.messageKey;
35
+ this.params = body?.params;
36
+ this.details = body?.details;
37
+ this.requestId = opts.requestId;
38
+ this.expected = opts.expected ?? false;
39
+ }
40
+ }
41
+ /**
42
+ * An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
43
+ * component waiting on it.
44
+ *
45
+ * Worth its own predicate because it is the one failure that must NOT be reported, retried or
46
+ * shown: nobody is waiting for the answer. Distinguishing it from a real failure is what keeps
47
+ * a route change from filling the console with errors nobody caused.
48
+ */
49
+ export function isAbortError(error) {
50
+ return error instanceof DOMException && error.name === "AbortError";
51
+ }
52
+ //# sourceMappingURL=api-error.js.map
@@ -0,0 +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;IAEvC;;;;;;;;OAQG;IACM,QAAQ,CAAU;IAE3B,YACE,MAAc,EACd,IAAkC,EAClC,OAAqE,EAAE;QAEvE,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,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"}
@@ -0,0 +1,49 @@
1
+ import { type StoreApi, type UseBoundStore } from "zustand";
2
+ /**
3
+ * Session FLAGS only.
4
+ *
5
+ * Who the person is — their plan, their staff bit, whether they are suspended — comes from
6
+ * `GET /auth/me` through react-query, never from here. Mirroring server state in a client store
7
+ * is how two sources of truth start disagreeing, and the one that is wrong is always the one on
8
+ * screen. Three repos wrote this same store; two of them wrote that same warning in a comment.
9
+ *
10
+ * `TUser` stays generic because it is the one part that differs: one product carries an
11
+ * anonymous-browsing flag, another does not. Keep it to what a GUARD needs — an id and an
12
+ * email is what all three donors had.
13
+ */
14
+ export interface AuthState<TUser> {
15
+ user: TUser | null;
16
+ isAuthenticated: boolean;
17
+ /**
18
+ * True until the first session lookup resolves. The guards must not bounce somebody to
19
+ * /login while we are still finding out whether they are signed in.
20
+ */
21
+ isLoading: boolean;
22
+ /**
23
+ * Set (or clear) the signed-in user. This ENDS the loading state — knowing who they are is
24
+ * what the bootstrap was waiting for. Two of the three donors left `isLoading` alone here and
25
+ * relied on a separate `setLoading(false)`; forgetting that call leaves every guard spinning,
26
+ * so the safe default is to do it in one write.
27
+ */
28
+ setUser: (user: TUser | null) => void;
29
+ setLoading: (isLoading: boolean) => void;
30
+ /** Sign-out: clears the user and ends the loading state in one write. */
31
+ clear: () => void;
32
+ }
33
+ /**
34
+ * Create the store.
35
+ *
36
+ * The whole reason this is a factory and not a store: `isLoading` must start FALSE where there
37
+ * is no window, and only a factory can decide that at the call site rather than at import.
38
+ *
39
+ * A session bootstrap can only be in flight in a browser. The BUILD renders this app to files
40
+ * with no window at all, so `true` there is a wait that never ends — one donor shipped a route
41
+ * guard holding its loading screen forever, and prerendered a public page as 1,174 bytes of
42
+ * `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be
43
+ * found. With no browser there is no session and never will be, which is exactly the state a
44
+ * first-time visitor arrives in, so that is what the page should render.
45
+ *
46
+ * Only one of the three donors knew this. It is invariant 7.
47
+ */
48
+ export declare function createAuthStore<TUser>(): UseBoundStore<StoreApi<AuthState<TUser>>>;
49
+ //# sourceMappingURL=auth-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-store.d.ts","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,SAAS,CAAC,KAAK;IAC9B,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI,CAAC;IACtC,UAAU,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACzC,yEAAyE;IACzE,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,KAAK,aAAa,CAAC,QAAQ,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CASlF"}