@djangocfg/api 2.1.333 → 2.1.335

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/api",
3
- "version": "2.1.333",
3
+ "version": "2.1.335",
4
4
  "description": "Auto-generated TypeScript API client with React hooks, SWR integration, and Zod validation for Django REST Framework backends",
5
5
  "keywords": [
6
6
  "django",
@@ -79,7 +79,7 @@
79
79
  "devDependencies": {
80
80
  "@types/node": "^24.7.2",
81
81
  "@types/react": "^19.1.0",
82
- "@djangocfg/typescript-config": "^2.1.333",
82
+ "@djangocfg/typescript-config": "^2.1.335",
83
83
  "next": "^16.2.2",
84
84
  "react": "^19.1.0",
85
85
  "tsup": "^8.5.0",
@@ -61,6 +61,18 @@ export class API {
61
61
  setLocale(locale: string | null): void { auth.setLocale(locale); }
62
62
  getApiKey(): string | null { return auth.getApiKey(); }
63
63
  setApiKey(key: string | null): void { auth.setApiKey(key); }
64
+
65
+ // ── 401 handling ────────────────────────────────────────────────────────
66
+ /** Fired only on terminal 401 (after refresh+retry path is exhausted). */
67
+ onUnauthorized(cb: ((response: Response) => void) | null): void {
68
+ auth.onUnauthorized(cb);
69
+ }
70
+ /** Provide a refresh strategy. See `auth.setRefreshHandler` for the contract. */
71
+ setRefreshHandler(
72
+ fn: ((refreshToken: string) => Promise<{ access: string; refresh?: string } | null>) | null,
73
+ ): void {
74
+ auth.setRefreshHandler(fn);
75
+ }
64
76
  }
65
77
 
66
78
  export { };
@@ -61,6 +61,18 @@ export class API {
61
61
  setLocale(locale: string | null): void { auth.setLocale(locale); }
62
62
  getApiKey(): string | null { return auth.getApiKey(); }
63
63
  setApiKey(key: string | null): void { auth.setApiKey(key); }
64
+
65
+ // ── 401 handling ────────────────────────────────────────────────────────
66
+ /** Fired only on terminal 401 (after refresh+retry path is exhausted). */
67
+ onUnauthorized(cb: ((response: Response) => void) | null): void {
68
+ auth.onUnauthorized(cb);
69
+ }
70
+ /** Provide a refresh strategy. See `auth.setRefreshHandler` for the contract. */
71
+ setRefreshHandler(
72
+ fn: ((refreshToken: string) => Promise<{ access: string; refresh?: string } | null>) | null,
73
+ ): void {
74
+ auth.setRefreshHandler(fn);
75
+ }
64
76
  }
65
77
 
66
78
  export { };
@@ -61,6 +61,18 @@ export class API {
61
61
  setLocale(locale: string | null): void { auth.setLocale(locale); }
62
62
  getApiKey(): string | null { return auth.getApiKey(); }
63
63
  setApiKey(key: string | null): void { auth.setApiKey(key); }
64
+
65
+ // ── 401 handling ────────────────────────────────────────────────────────
66
+ /** Fired only on terminal 401 (after refresh+retry path is exhausted). */
67
+ onUnauthorized(cb: ((response: Response) => void) | null): void {
68
+ auth.onUnauthorized(cb);
69
+ }
70
+ /** Provide a refresh strategy. See `auth.setRefreshHandler` for the contract. */
71
+ setRefreshHandler(
72
+ fn: ((refreshToken: string) => Promise<{ access: string; refresh?: string } | null>) | null,
73
+ ): void {
74
+ auth.setRefreshHandler(fn);
75
+ }
64
76
  }
65
77
 
66
78
  export { };
@@ -105,6 +105,21 @@ let _baseUrlOverride: string | null = null;
105
105
  let _withCredentials = true;
106
106
  let _onUnauthorized: ((response: Response) => void) | null = null;
107
107
 
108
+ /**
109
+ * User-supplied refresh handler. Receives the current refresh token,
110
+ * must return a fresh access (and optional refresh) pair or null on failure.
111
+ * Set once at app bootstrap via `auth.setRefreshHandler(...)`.
112
+ */
113
+ type RefreshResult = { access: string; refresh?: string } | null;
114
+ type RefreshHandler = (refreshToken: string) => Promise<RefreshResult>;
115
+ let _refreshHandler: RefreshHandler | null = null;
116
+
117
+ /** Single-flight: every concurrent 401 awaits the same refresh. */
118
+ let _refreshInflight: Promise<string | null> | null = null;
119
+
120
+ /** Marker header — set on retried requests so we never loop on 401. */
121
+ const RETRY_MARKER = 'X-Auth-Retry';
122
+
108
123
  /**
109
124
  * Captured reference to the shared Hey API client. Set exactly once by
110
125
  * `installAuthOnClient(client)` (called from client.gen.ts). All `auth.set*`
@@ -192,11 +207,62 @@ export const auth = {
192
207
  },
193
208
 
194
209
  // ── 401 handler ───────────────────────────────────────────────────
210
+ /**
211
+ * Fired when the server returns 401 AND no refresh path recovers it
212
+ * (no refresh token, no refresh handler, refresh failed, or retry
213
+ * still 401). The app should clear local state and redirect to login.
214
+ *
215
+ * NOT fired for 401 that gets transparently recovered by the refresh
216
+ * handler — those are invisible to callers.
217
+ */
195
218
  onUnauthorized(cb: ((response: Response) => void) | null): void {
196
219
  _onUnauthorized = cb;
197
220
  },
221
+
222
+ /**
223
+ * Register the refresh strategy. The handler receives the current
224
+ * refresh token and must call your refresh endpoint, returning
225
+ * `{ access, refresh? }` on success or `null` on failure.
226
+ *
227
+ * @example
228
+ * auth.setRefreshHandler(async (refresh) => {
229
+ * const { data } = await Auth.tokenRefreshCreate({ body: { refresh } });
230
+ * return data ? { access: data.access, refresh: data.refresh } : null;
231
+ * });
232
+ */
233
+ setRefreshHandler(fn: RefreshHandler | null): void {
234
+ _refreshHandler = fn;
235
+ },
198
236
  };
199
237
 
238
+ /**
239
+ * Run the user-supplied refresh handler under single-flight, persist
240
+ * the new tokens, and return the fresh access token (or null on any
241
+ * failure path). All concurrent 401s share the same in-flight promise.
242
+ */
243
+ async function tryRefresh(): Promise<string | null> {
244
+ if (_refreshInflight) return _refreshInflight;
245
+ if (!_refreshHandler) return null;
246
+ const refresh = auth.getRefreshToken();
247
+ if (!refresh) return null;
248
+
249
+ _refreshInflight = (async () => {
250
+ try {
251
+ const result = await _refreshHandler!(refresh);
252
+ if (!result?.access) return null;
253
+ auth.setToken(result.access);
254
+ if (result.refresh) auth.setRefreshToken(result.refresh);
255
+ return result.access;
256
+ } catch {
257
+ return null;
258
+ } finally {
259
+ _refreshInflight = null;
260
+ }
261
+ })();
262
+
263
+ return _refreshInflight;
264
+ }
265
+
200
266
  /**
201
267
  * Wire the shared client to the global auth store. Called exactly
202
268
  * once from `client.gen.ts` (post-processed) right after
@@ -229,11 +295,44 @@ export function installAuthOnClient(client: HeyClient): void {
229
295
  return request;
230
296
  });
231
297
 
232
- client.interceptors.response.use((response) => {
233
- if (response.status === 401 && _onUnauthorized) {
234
- try { _onUnauthorized(response); } catch {}
298
+ client.interceptors.response.use(async (response, request) => {
299
+ if (response.status !== 401) return response;
300
+
301
+ // Already retried once — give up to avoid loops.
302
+ if (request.headers.get(RETRY_MARKER)) {
303
+ if (_onUnauthorized) {
304
+ try { _onUnauthorized(response); } catch {}
305
+ }
306
+ return response;
307
+ }
308
+
309
+ const newToken = await tryRefresh();
310
+ if (!newToken) {
311
+ if (_onUnauthorized) {
312
+ try { _onUnauthorized(response); } catch {}
313
+ }
314
+ return response;
315
+ }
316
+
317
+ // Retry the original request once with the new token. We mutate a
318
+ // clone so the original Request body (already consumed by the
319
+ // failed call) doesn't trip "body already used".
320
+ const retry = request.clone();
321
+ retry.headers.set('Authorization', `Bearer ${newToken}`);
322
+ retry.headers.set(RETRY_MARKER, '1');
323
+ try {
324
+ const retried = await fetch(retry);
325
+ if (retried.status === 401 && _onUnauthorized) {
326
+ try { _onUnauthorized(retried); } catch {}
327
+ }
328
+ return retried;
329
+ } catch {
330
+ // Network error on retry — surface the original 401.
331
+ if (_onUnauthorized) {
332
+ try { _onUnauthorized(response); } catch {}
333
+ }
334
+ return response;
235
335
  }
236
- return response;
237
336
  });
238
337
  }
239
338