@gusnips/react 0.8.0 → 0.8.1
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/dist/api-client.d.ts +4 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +30 -7
- package/dist/api-client.js.map +1 -1
- package/package.json +1 -1
- package/src/api-client.test.ts +66 -0
- package/src/api-client.ts +34 -7
package/dist/api-client.d.ts
CHANGED
|
@@ -63,6 +63,10 @@ export interface ApiClientOptions {
|
|
|
63
63
|
* `location.replace()` does not stop the current frame, so a caller's own `onError` must
|
|
64
64
|
* still run or a half-finished screen keeps rendering against data that will never arrive.
|
|
65
65
|
* The error it throws carries `expected: true`.
|
|
66
|
+
*
|
|
67
|
+
* Ending the session IN PLACE is equally supported — routing to the sign-in screen without a
|
|
68
|
+
* reload — and it is the case that keeps this client alive across the next sign-in. The
|
|
69
|
+
* sign-out latch is keyed on the refused token for exactly that reason; see `refusedToken`.
|
|
66
70
|
*/
|
|
67
71
|
onSessionDead: () => void;
|
|
68
72
|
/**
|
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;AAC5D,OAAO,EAAE,QAAQ,EAAmB,MAAM,gBAAgB,CAAC;AAE3D;;;;;;;;;;;;;;;;;;GAkBG;AAEH,+CAA+C;AAC/C,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB;;;;;;;;;OASG;IACH,WAAW,EAAE,OAAO,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACnC,OAAO,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC;IAClC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,cAAc,CAAC;IACxB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC
|
|
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
|
@@ -76,11 +76,23 @@ export function createApiClient({ baseUrl, session, headers: extraHeaders, onSes
|
|
|
76
76
|
});
|
|
77
77
|
return refreshing;
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Latches, so whichever request gets here first owns the sign-out and the rest are no-ops.
|
|
81
|
+
*
|
|
82
|
+
* Keyed on the token that was REFUSED rather than on a boolean, because the latch is about one
|
|
83
|
+
* session and not about the client. An adopter that ends a dead session in place — routing to
|
|
84
|
+
* the sign-in screen instead of reloading — keeps this client across the next sign-in, and a
|
|
85
|
+
* flag that never clears means the second dead session in that tab only throws: nothing calls
|
|
86
|
+
* `onSessionDead`, so every request 401s with nothing left to redirect it. That is the state
|
|
87
|
+
* `signOutTimeoutMs` exists to prevent, reached from the other side, and an adopter hit it.
|
|
88
|
+
*
|
|
89
|
+
* `undefined` means no session has been refused. `null` is a real value here — a request with
|
|
90
|
+
* no token at all can be the one auth answers "no" to.
|
|
91
|
+
*/
|
|
92
|
+
let refusedToken;
|
|
93
|
+
function sessionDead(token) {
|
|
94
|
+
if (refusedToken === undefined) {
|
|
95
|
+
refusedToken = token;
|
|
84
96
|
let done = false;
|
|
85
97
|
const go = () => {
|
|
86
98
|
if (done)
|
|
@@ -120,12 +132,22 @@ export function createApiClient({ baseUrl, session, headers: extraHeaders, onSes
|
|
|
120
132
|
}
|
|
121
133
|
}
|
|
122
134
|
async function request(path, options = {}) {
|
|
123
|
-
|
|
135
|
+
const token = await session.getToken();
|
|
136
|
+
// A token that is not the one already refused is a new session, and signing back in inside
|
|
137
|
+
// the same tab is the only thing that produces one. That releases the latch.
|
|
138
|
+
if (refusedToken !== undefined && token !== null && token !== refusedToken)
|
|
139
|
+
refusedToken = undefined;
|
|
140
|
+
let res = await send(path, token, options);
|
|
124
141
|
if (res.status === 401) {
|
|
125
142
|
// Only a refresh that actually REACHED auth proves the session is gone. Anything else is
|
|
126
143
|
// a network problem, and the 401 falls through as an ordinary error — the next request
|
|
127
144
|
// refreshes cleanly once the connection is back. Invariant 3.
|
|
128
145
|
let answered = false;
|
|
146
|
+
// The NEWEST token that was refused, which is what the latch is keyed on. Latching the one
|
|
147
|
+
// the request started with would be wrong: a refresh that yields a token auth then also
|
|
148
|
+
// refuses leaves `getToken` answering that newer one, so the very next request would read
|
|
149
|
+
// it as a fresh session and sign the same dead one out twice.
|
|
150
|
+
let refused = token;
|
|
129
151
|
for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
|
|
130
152
|
if (attempt > 0)
|
|
131
153
|
await sleep(refreshRetryDelayMs);
|
|
@@ -133,12 +155,13 @@ export function createApiClient({ baseUrl, session, headers: extraHeaders, onSes
|
|
|
133
155
|
answered = result.reachedAuth;
|
|
134
156
|
if (!result.token)
|
|
135
157
|
continue;
|
|
158
|
+
refused = result.token;
|
|
136
159
|
res = await send(path, result.token, options);
|
|
137
160
|
if (res.status !== 401)
|
|
138
161
|
break;
|
|
139
162
|
}
|
|
140
163
|
if (res.status === 401 && answered)
|
|
141
|
-
sessionDead();
|
|
164
|
+
sessionDead(refused);
|
|
142
165
|
}
|
|
143
166
|
if (!res.ok) {
|
|
144
167
|
const body = await res.json().catch(() => null);
|
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,QAAQ,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|
package/src/api-client.test.ts
CHANGED
|
@@ -185,6 +185,72 @@ describe("createApiClient", () => {
|
|
|
185
185
|
expect((first as ApiError).expected).toBe(true);
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The latch is about one SESSION, not about the client.
|
|
190
|
+
*
|
|
191
|
+
* An adopter that ends a dead session IN PLACE — routing to the sign-in screen rather than
|
|
192
|
+
* reloading — keeps this client across the next sign-in. A latch that never clears means the
|
|
193
|
+
* SECOND dead session in that tab only throws: nothing calls `onSessionDead`, so every
|
|
194
|
+
* request 401s with nothing left to redirect it. That is the same end state the sign-out
|
|
195
|
+
* fail-safe timer exists to prevent, reached from the other side.
|
|
196
|
+
*/
|
|
197
|
+
it("signs out again after the tab signs back in", async () => {
|
|
198
|
+
stubFetch(() => unauthorized());
|
|
199
|
+
const onSessionDead = vi.fn();
|
|
200
|
+
let token = "first-session";
|
|
201
|
+
const api = createApiClient({
|
|
202
|
+
baseUrl: "https://api.test",
|
|
203
|
+
session: session({
|
|
204
|
+
getToken: async () => token,
|
|
205
|
+
refresh: async () => ({ token: null, reachedAuth: true }),
|
|
206
|
+
}),
|
|
207
|
+
onSessionDead,
|
|
208
|
+
refreshRetryDelayMs: 0,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await api.get("/a").catch(() => {});
|
|
212
|
+
expect(onSessionDead).toHaveBeenCalledTimes(1);
|
|
213
|
+
|
|
214
|
+
// The person signs back in without reloading: same client, a different token.
|
|
215
|
+
token = "second-session";
|
|
216
|
+
await api.get("/b").catch(() => {});
|
|
217
|
+
expect(onSessionDead).toHaveBeenCalledTimes(2);
|
|
218
|
+
|
|
219
|
+
// …and the second session latches on its own, exactly as the first one did.
|
|
220
|
+
await api.get("/c").catch(() => {});
|
|
221
|
+
expect(onSessionDead).toHaveBeenCalledTimes(2);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* …and the latch holds the NEWEST refused token, not the one the request started with.
|
|
226
|
+
*
|
|
227
|
+
* A refresh can hand back a rotated token that auth then also refuses. The adapter stores it,
|
|
228
|
+
* so `getToken` answers with it from then on — and a latch keyed on the token the request
|
|
229
|
+
* began with would read that as a fresh session and sign the same dead one out twice.
|
|
230
|
+
*/
|
|
231
|
+
it("does not sign out twice when the refused token was itself a refresh", async () => {
|
|
232
|
+
stubFetch(() => unauthorized());
|
|
233
|
+
const onSessionDead = vi.fn();
|
|
234
|
+
let token = "old-token";
|
|
235
|
+
const api = createApiClient({
|
|
236
|
+
baseUrl: "https://api.test",
|
|
237
|
+
session: session({
|
|
238
|
+
getToken: async () => token,
|
|
239
|
+
refresh: async () => {
|
|
240
|
+
// What a rotating auth server does: a new token the API still refuses.
|
|
241
|
+
token = "rotated-token";
|
|
242
|
+
return { token, reachedAuth: true };
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
onSessionDead,
|
|
246
|
+
refreshRetryDelayMs: 0,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await api.get("/a").catch(() => {});
|
|
250
|
+
await api.get("/b").catch(() => {});
|
|
251
|
+
expect(onSessionDead).toHaveBeenCalledTimes(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
188
254
|
/**
|
|
189
255
|
* providerkit's invariant 2, on the web side: a caller's Stop and our deadline must stay
|
|
190
256
|
* distinguishable. The deadline aborts with a **TimeoutError**, not an AbortError, so
|
package/src/api-client.ts
CHANGED
|
@@ -67,6 +67,10 @@ export interface ApiClientOptions {
|
|
|
67
67
|
* `location.replace()` does not stop the current frame, so a caller's own `onError` must
|
|
68
68
|
* still run or a half-finished screen keeps rendering against data that will never arrive.
|
|
69
69
|
* The error it throws carries `expected: true`.
|
|
70
|
+
*
|
|
71
|
+
* Ending the session IN PLACE is equally supported — routing to the sign-in screen without a
|
|
72
|
+
* reload — and it is the case that keeps this client alive across the next sign-in. The
|
|
73
|
+
* sign-out latch is keyed on the refused token for exactly that reason; see `refusedToken`.
|
|
70
74
|
*/
|
|
71
75
|
onSessionDead: () => void;
|
|
72
76
|
/**
|
|
@@ -265,11 +269,23 @@ export function createApiClient({
|
|
|
265
269
|
return refreshing;
|
|
266
270
|
}
|
|
267
271
|
|
|
268
|
-
/**
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
272
|
+
/**
|
|
273
|
+
* Latches, so whichever request gets here first owns the sign-out and the rest are no-ops.
|
|
274
|
+
*
|
|
275
|
+
* Keyed on the token that was REFUSED rather than on a boolean, because the latch is about one
|
|
276
|
+
* session and not about the client. An adopter that ends a dead session in place — routing to
|
|
277
|
+
* the sign-in screen instead of reloading — keeps this client across the next sign-in, and a
|
|
278
|
+
* flag that never clears means the second dead session in that tab only throws: nothing calls
|
|
279
|
+
* `onSessionDead`, so every request 401s with nothing left to redirect it. That is the state
|
|
280
|
+
* `signOutTimeoutMs` exists to prevent, reached from the other side, and an adopter hit it.
|
|
281
|
+
*
|
|
282
|
+
* `undefined` means no session has been refused. `null` is a real value here — a request with
|
|
283
|
+
* no token at all can be the one auth answers "no" to.
|
|
284
|
+
*/
|
|
285
|
+
let refusedToken: string | null | undefined;
|
|
286
|
+
function sessionDead(token: string | null): never {
|
|
287
|
+
if (refusedToken === undefined) {
|
|
288
|
+
refusedToken = token;
|
|
273
289
|
let done = false;
|
|
274
290
|
const go = (): void => {
|
|
275
291
|
if (done) return;
|
|
@@ -318,22 +334,33 @@ export function createApiClient({
|
|
|
318
334
|
}
|
|
319
335
|
|
|
320
336
|
async function request(path: string, options: RequestOptions = {}): Promise<Response> {
|
|
321
|
-
|
|
337
|
+
const token = await session.getToken();
|
|
338
|
+
// A token that is not the one already refused is a new session, and signing back in inside
|
|
339
|
+
// the same tab is the only thing that produces one. That releases the latch.
|
|
340
|
+
if (refusedToken !== undefined && token !== null && token !== refusedToken)
|
|
341
|
+
refusedToken = undefined;
|
|
342
|
+
let res = await send(path, token, options);
|
|
322
343
|
|
|
323
344
|
if (res.status === 401) {
|
|
324
345
|
// Only a refresh that actually REACHED auth proves the session is gone. Anything else is
|
|
325
346
|
// a network problem, and the 401 falls through as an ordinary error — the next request
|
|
326
347
|
// refreshes cleanly once the connection is back. Invariant 3.
|
|
327
348
|
let answered = false;
|
|
349
|
+
// The NEWEST token that was refused, which is what the latch is keyed on. Latching the one
|
|
350
|
+
// the request started with would be wrong: a refresh that yields a token auth then also
|
|
351
|
+
// refuses leaves `getToken` answering that newer one, so the very next request would read
|
|
352
|
+
// it as a fresh session and sign the same dead one out twice.
|
|
353
|
+
let refused = token;
|
|
328
354
|
for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
|
|
329
355
|
if (attempt > 0) await sleep(refreshRetryDelayMs);
|
|
330
356
|
const result = await refresh();
|
|
331
357
|
answered = result.reachedAuth;
|
|
332
358
|
if (!result.token) continue;
|
|
359
|
+
refused = result.token;
|
|
333
360
|
res = await send(path, result.token, options);
|
|
334
361
|
if (res.status !== 401) break;
|
|
335
362
|
}
|
|
336
|
-
if (res.status === 401 && answered) sessionDead();
|
|
363
|
+
if (res.status === 401 && answered) sessionDead(refused);
|
|
337
364
|
}
|
|
338
365
|
|
|
339
366
|
if (!res.ok) {
|