@gusnips/react 0.7.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 +17 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +31 -8
- package/dist/api-client.js.map +1 -1
- package/package.json +1 -1
- package/src/api-client.test.ts +93 -0
- package/src/api-client.ts +48 -9
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
|
/**
|
|
@@ -80,8 +84,20 @@ export interface ApiClientOptions {
|
|
|
80
84
|
*
|
|
81
85
|
* Observation only: the error is thrown either way, and throwing from here would replace a
|
|
82
86
|
* real API failure with whatever the listener hit.
|
|
87
|
+
*
|
|
88
|
+
* **The response comes with it, because a refusal's headers are otherwise unreachable.**
|
|
89
|
+
* `request` throws before the `Response` escapes, so an adopter whose API states something on
|
|
90
|
+
* the error path had no way to read it at all — one reports what every call cost, refusals
|
|
91
|
+
* included, which is how "a blocked page costs nothing" becomes a thing a customer can check
|
|
92
|
+
* rather than a thing we assert. That is the distinction worth keeping: field-error flattening
|
|
93
|
+
* and a per-call meter callback both stayed in their products, because a product can write
|
|
94
|
+
* those itself. This one it cannot, at any price, and a capability the kit makes unreachable
|
|
95
|
+
* is a regression the kit has to undo.
|
|
96
|
+
*
|
|
97
|
+
* The BODY has already been read by the time this runs — the envelope was needed to build the
|
|
98
|
+
* error — so `response.json()` here throws. The headers are what is left, and what this is for.
|
|
83
99
|
*/
|
|
84
|
-
onError?: (error: ApiError) => void;
|
|
100
|
+
onError?: (error: ApiError, response: Response) => void;
|
|
85
101
|
/**
|
|
86
102
|
* Abort a request that has not answered. Default 30s.
|
|
87
103
|
*
|
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);
|
|
@@ -150,7 +173,7 @@ export function createApiClient({ baseUrl, session, headers: extraHeaders, onSes
|
|
|
150
173
|
// the real news, and swallowing it for a bug in a side effect would send everyone
|
|
151
174
|
// debugging the wrong thing.
|
|
152
175
|
try {
|
|
153
|
-
onError?.(error);
|
|
176
|
+
onError?.(error, res);
|
|
154
177
|
}
|
|
155
178
|
catch {
|
|
156
179
|
// ignored on purpose — see above
|
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.
|
|
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
|
|
@@ -336,6 +402,33 @@ describe("createApiClient", () => {
|
|
|
336
402
|
expect(thrown).toBe(seen[0]);
|
|
337
403
|
});
|
|
338
404
|
|
|
405
|
+
/**
|
|
406
|
+
* A refusal's headers reach nobody otherwise: `request` throws before the `Response` escapes.
|
|
407
|
+
* One adopter's API reports what every call cost on the error path too — `x-request-cost: 0`
|
|
408
|
+
* on a page that was walled — which is the whole promise "a blocked page costs nothing" made
|
|
409
|
+
* checkable. Without the response here, adopting this client would have deleted that screen.
|
|
410
|
+
*/
|
|
411
|
+
it("hands the failed response to onError, headers and all", async () => {
|
|
412
|
+
stubFetch(
|
|
413
|
+
() =>
|
|
414
|
+
new Response(JSON.stringify({ error: { code: "UPSTREAM_BLOCKED", message: "walled" } }), {
|
|
415
|
+
status: 422,
|
|
416
|
+
headers: { "x-request-cost": "0" },
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
const seen: { code: string | undefined; cost: string | null }[] = [];
|
|
420
|
+
const api = createApiClient({
|
|
421
|
+
baseUrl: "https://api.test",
|
|
422
|
+
session: session(),
|
|
423
|
+
onSessionDead: () => {},
|
|
424
|
+
onError: (error, response) =>
|
|
425
|
+
seen.push({ code: error.code, cost: response.headers.get("x-request-cost") }),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await api.get("/v1/scrape").catch(() => {});
|
|
429
|
+
expect(seen).toEqual([{ code: "UPSTREAM_BLOCKED", cost: "0" }]);
|
|
430
|
+
});
|
|
431
|
+
|
|
339
432
|
it("does not let a broken listener replace the API failure", async () => {
|
|
340
433
|
stubFetch(
|
|
341
434
|
() =>
|
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
|
/**
|
|
@@ -84,8 +88,20 @@ export interface ApiClientOptions {
|
|
|
84
88
|
*
|
|
85
89
|
* Observation only: the error is thrown either way, and throwing from here would replace a
|
|
86
90
|
* real API failure with whatever the listener hit.
|
|
91
|
+
*
|
|
92
|
+
* **The response comes with it, because a refusal's headers are otherwise unreachable.**
|
|
93
|
+
* `request` throws before the `Response` escapes, so an adopter whose API states something on
|
|
94
|
+
* the error path had no way to read it at all — one reports what every call cost, refusals
|
|
95
|
+
* included, which is how "a blocked page costs nothing" becomes a thing a customer can check
|
|
96
|
+
* rather than a thing we assert. That is the distinction worth keeping: field-error flattening
|
|
97
|
+
* and a per-call meter callback both stayed in their products, because a product can write
|
|
98
|
+
* those itself. This one it cannot, at any price, and a capability the kit makes unreachable
|
|
99
|
+
* is a regression the kit has to undo.
|
|
100
|
+
*
|
|
101
|
+
* The BODY has already been read by the time this runs — the envelope was needed to build the
|
|
102
|
+
* error — so `response.json()` here throws. The headers are what is left, and what this is for.
|
|
87
103
|
*/
|
|
88
|
-
onError?: (error: ApiError) => void;
|
|
104
|
+
onError?: (error: ApiError, response: Response) => void;
|
|
89
105
|
/**
|
|
90
106
|
* Abort a request that has not answered. Default 30s.
|
|
91
107
|
*
|
|
@@ -253,11 +269,23 @@ export function createApiClient({
|
|
|
253
269
|
return refreshing;
|
|
254
270
|
}
|
|
255
271
|
|
|
256
|
-
/**
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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;
|
|
261
289
|
let done = false;
|
|
262
290
|
const go = (): void => {
|
|
263
291
|
if (done) return;
|
|
@@ -306,22 +334,33 @@ export function createApiClient({
|
|
|
306
334
|
}
|
|
307
335
|
|
|
308
336
|
async function request(path: string, options: RequestOptions = {}): Promise<Response> {
|
|
309
|
-
|
|
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);
|
|
310
343
|
|
|
311
344
|
if (res.status === 401) {
|
|
312
345
|
// Only a refresh that actually REACHED auth proves the session is gone. Anything else is
|
|
313
346
|
// a network problem, and the 401 falls through as an ordinary error — the next request
|
|
314
347
|
// refreshes cleanly once the connection is back. Invariant 3.
|
|
315
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;
|
|
316
354
|
for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
|
|
317
355
|
if (attempt > 0) await sleep(refreshRetryDelayMs);
|
|
318
356
|
const result = await refresh();
|
|
319
357
|
answered = result.reachedAuth;
|
|
320
358
|
if (!result.token) continue;
|
|
359
|
+
refused = result.token;
|
|
321
360
|
res = await send(path, result.token, options);
|
|
322
361
|
if (res.status !== 401) break;
|
|
323
362
|
}
|
|
324
|
-
if (res.status === 401 && answered) sessionDead();
|
|
363
|
+
if (res.status === 401 && answered) sessionDead(refused);
|
|
325
364
|
}
|
|
326
365
|
|
|
327
366
|
if (!res.ok) {
|
|
@@ -334,7 +373,7 @@ export function createApiClient({
|
|
|
334
373
|
// the real news, and swallowing it for a bug in a side effect would send everyone
|
|
335
374
|
// debugging the wrong thing.
|
|
336
375
|
try {
|
|
337
|
-
onError?.(error);
|
|
376
|
+
onError?.(error, res);
|
|
338
377
|
} catch {
|
|
339
378
|
// ignored on purpose — see above
|
|
340
379
|
}
|