@behio/storefront-sdk 2.0.1 → 2.0.2

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/react.mjs CHANGED
@@ -134,6 +134,8 @@ var BehioStorefront = class {
134
134
  // Token refresh lock
135
135
  this.isRefreshing = false;
136
136
  this.refreshPromise = null;
137
+ this.requestContextVersion = 0;
138
+ this.authContextVersion = 0;
137
139
  // Event emitter
138
140
  this.listeners = /* @__PURE__ */ new Map();
139
141
  // Interceptors
@@ -177,6 +179,28 @@ var BehioStorefront = class {
177
179
  this.subscriptions = new SubscriptionsModule(this);
178
180
  this.certificates = new CourseCertificatesModule(this);
179
181
  }
182
+ /** @internal Opaque generation, never a token or customer identifier. */
183
+ getRequestContextVersion() {
184
+ return this.requestContextVersion;
185
+ }
186
+ /** @internal Refreshes must not restore a session that has been replaced. */
187
+ getAuthContextVersion() {
188
+ return this.authContextVersion;
189
+ }
190
+ /** @internal A refresh rotates credentials within the same customer session. */
191
+ acceptRefreshedTokens(tokens, expectedVersion) {
192
+ if (this.authContextVersion !== expectedVersion) return false;
193
+ if (!this.accessToken) this.setTokens(tokens);
194
+ else {
195
+ this.accessToken = tokens.accessToken;
196
+ this.refreshToken = tokens.refreshToken;
197
+ }
198
+ return true;
199
+ }
200
+ /** @internal An obsolete response must not update cache, session or retries. */
201
+ assertRequestContext(version, authOnly = false) {
202
+ if (version !== (authOnly ? this.authContextVersion : this.requestContextVersion)) throw new BehioApiError(409, null, "The storefront session or pricing context changed. Please retry in the current context.");
203
+ }
180
204
  // --- Public methods ---
181
205
  /**
182
206
  * Called by the analytics tracker when the visitor grants (id) or revokes
@@ -270,11 +294,19 @@ var BehioStorefront = class {
270
294
  }
271
295
  /** Set auth tokens (e.g. from localStorage) */
272
296
  setTokens(tokens) {
297
+ if (this.accessToken !== tokens.accessToken || this.refreshToken !== tokens.refreshToken) {
298
+ this.authContextVersion++;
299
+ this.requestContextVersion++;
300
+ }
273
301
  this.accessToken = tokens.accessToken;
274
302
  this.refreshToken = tokens.refreshToken;
275
303
  }
276
304
  /** Clear auth tokens */
277
305
  clearTokens() {
306
+ if (this.accessToken || this.refreshToken) {
307
+ this.authContextVersion++;
308
+ this.requestContextVersion++;
309
+ }
278
310
  this.accessToken = void 0;
279
311
  this.refreshToken = void 0;
280
312
  }
@@ -288,6 +320,7 @@ var BehioStorefront = class {
288
320
  }
289
321
  /** Set cart session token (e.g. from cookie) */
290
322
  setCartSession(token) {
323
+ if (this.cartSession !== token) this.requestContextVersion++;
291
324
  this.cartSession = token;
292
325
  }
293
326
  /** Get cart session token */
@@ -296,6 +329,7 @@ var BehioStorefront = class {
296
329
  }
297
330
  /** Clear cart session */
298
331
  clearCartSession() {
332
+ if (this.cartSession) this.requestContextVersion++;
299
333
  this.cartSession = void 0;
300
334
  }
301
335
  // --- Default currency / locale (runtime-switchable) ---
@@ -306,6 +340,7 @@ var BehioStorefront = class {
306
340
  * (falls back to the shop's default currency server-side).
307
341
  */
308
342
  setCurrency(currency) {
343
+ if (this.defaultCurrency !== (currency || void 0)) this.requestContextVersion++;
309
344
  this.defaultCurrency = currency || void 0;
310
345
  }
311
346
  /** Get the current default currency, if any. */
@@ -315,13 +350,16 @@ var BehioStorefront = class {
315
350
  /** Set the catalog's delivery country. Use cart.setDestination separately for the cart. */
316
351
  setCountry(country) {
317
352
  const normalized = country?.trim().toUpperCase();
318
- this.defaultCountry = normalized && /^[A-Z]{2}$/.test(normalized) ? normalized : void 0;
353
+ const next = normalized && /^[A-Z]{2}$/.test(normalized) ? normalized : void 0;
354
+ if (this.defaultCountry !== next) this.requestContextVersion++;
355
+ this.defaultCountry = next;
319
356
  }
320
357
  getCountry() {
321
358
  return this.defaultCountry;
322
359
  }
323
360
  /** Set the default locale sent on every catalog request (per-call wins). */
324
361
  setLocale(locale) {
362
+ if (this.defaultLocale !== (locale || void 0)) this.requestContextVersion++;
325
363
  this.defaultLocale = locale || void 0;
326
364
  }
327
365
  /** Get the current default locale, if any. */
@@ -377,18 +415,17 @@ var BehioStorefront = class {
377
415
  return;
378
416
  }
379
417
  this.isRefreshing = true;
418
+ const authVersion = this.authContextVersion;
380
419
  this.refreshPromise = (async () => {
381
420
  try {
382
421
  const res = await this.auth.refresh();
383
- if (res.error) {
422
+ if (res.error) throw new BehioApiError(res.error.status ?? 401, res.error.body, res.error.message);
423
+ if (this.authContextVersion === authVersion) this.emit("auth:token-refresh");
424
+ } catch (err) {
425
+ if (this.authContextVersion === authVersion) {
384
426
  this.clearTokens();
385
427
  this.emit("auth:token-refresh-failed");
386
- throw new BehioApiError(401, null, "Token refresh failed");
387
428
  }
388
- this.emit("auth:token-refresh");
389
- } catch (err) {
390
- this.clearTokens();
391
- this.emit("auth:token-refresh-failed");
392
429
  if (err instanceof BehioApiError) throw err;
393
430
  throw new BehioApiError(401, null, "Token refresh failed");
394
431
  } finally {
@@ -425,6 +462,7 @@ var BehioStorefront = class {
425
462
  * @internal — use the typed module methods (behio.certificates.downloadPdf).
426
463
  */
427
464
  async requestBlob(path) {
465
+ const authVersion = this.authContextVersion;
428
466
  try {
429
467
  const headers = { "X-Api-Key": this.apiKey };
430
468
  if (this.accessToken)
@@ -440,7 +478,9 @@ var BehioStorefront = class {
440
478
  }
441
479
  throw new BehioApiError(res.status, body);
442
480
  }
443
- return ok(await res.blob());
481
+ const blob = await res.blob();
482
+ this.assertRequestContext(authVersion, true);
483
+ return ok(blob);
444
484
  } catch (err) {
445
485
  if (this.throwOnAvailabilityError && isAvailabilityFailure(err)) {
446
486
  throw err;
@@ -454,6 +494,8 @@ var BehioStorefront = class {
454
494
  * public callers must go through `request()` which returns Result.
455
495
  */
456
496
  async rawRequest(method, path, options) {
497
+ const authOnly = path.startsWith("/auth/");
498
+ const requestVersion = authOnly ? this.authContextVersion : this.requestContextVersion;
457
499
  const params = new URLSearchParams();
458
500
  if (options?.query) {
459
501
  for (const [key, value] of Object.entries(options.query)) {
@@ -519,6 +561,7 @@ var BehioStorefront = class {
519
561
  }
520
562
  this.emit("request", { method, path });
521
563
  for (let attempt = 0; attempt <= this.retries; attempt++) {
564
+ this.assertRequestContext(requestVersion, authOnly);
522
565
  const controller = new AbortController();
523
566
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
524
567
  if (options?.signal) {
@@ -540,6 +583,7 @@ var BehioStorefront = class {
540
583
  });
541
584
  } catch (err) {
542
585
  clearTimeout(timeoutId);
586
+ this.assertRequestContext(requestVersion, authOnly);
543
587
  const isAbort = err instanceof DOMException && err.name === "AbortError";
544
588
  const networkErr = new BehioNetworkError(
545
589
  isAbort ? "Request timed out" : err.message || "Network error",
@@ -556,6 +600,7 @@ var BehioStorefront = class {
556
600
  } finally {
557
601
  clearTimeout(timeoutId);
558
602
  }
603
+ this.assertRequestContext(requestVersion, authOnly);
559
604
  const remaining = res.headers.get("X-RateLimit-Remaining");
560
605
  const reset = res.headers.get("X-RateLimit-Reset");
561
606
  if (remaining) this.rateLimitRemaining = parseInt(remaining, 10);
@@ -569,6 +614,7 @@ var BehioStorefront = class {
569
614
  this.emit("response", { method, path, status: res.status });
570
615
  if (!res.ok) {
571
616
  const body = await res.json().catch(() => null);
617
+ this.assertRequestContext(requestVersion, authOnly);
572
618
  const apiError = new BehioApiError(
573
619
  res.status,
574
620
  body,
@@ -577,11 +623,13 @@ var BehioStorefront = class {
577
623
  if (res.status === 401 && this.refreshToken && options?.auth !== false && !options?._isRetryAfterRefresh) {
578
624
  try {
579
625
  await this.handleTokenRefresh();
626
+ this.assertRequestContext(requestVersion, authOnly);
580
627
  return this.rawRequest(method, path, {
581
628
  ...options,
582
629
  _isRetryAfterRefresh: true
583
630
  });
584
- } catch {
631
+ } catch (error) {
632
+ if (error instanceof BehioApiError && error.status === 409) throw error;
585
633
  this.emit("error", apiError);
586
634
  throw apiError;
587
635
  }
@@ -606,6 +654,7 @@ var BehioStorefront = class {
606
654
  } catch {
607
655
  }
608
656
  }
657
+ this.assertRequestContext(requestVersion, authOnly);
609
658
  return responseData;
610
659
  }
611
660
  throw new BehioNetworkError("Request failed after retries");
@@ -950,17 +999,21 @@ var AuthModule = class {
950
999
  }
951
1000
  };
952
1001
  }
1002
+ const authVersion = this.client.getAuthContextVersion();
953
1003
  const res = await this.client.request("POST", "/auth/refresh", {
954
1004
  body: { refreshToken: token },
955
1005
  auth: false
956
1006
  });
957
1007
  if (res.error) return res;
958
- this.client.setTokens(res.data);
1008
+ if (!this.client.acceptRefreshedTokens(res.data, authVersion)) {
1009
+ return { data: null, error: toSdkError(new BehioApiError(409, null, "The customer session changed.")) };
1010
+ }
959
1011
  return res;
960
1012
  }
961
1013
  /** Logout (invalidate refresh token) */
962
1014
  async logout(refreshToken) {
963
1015
  const token = refreshToken || this.client.getRefreshToken();
1016
+ const authVersion = this.client.getAuthContextVersion();
964
1017
  const res = await this.client.request(
965
1018
  "POST",
966
1019
  "/auth/logout",
@@ -968,8 +1021,10 @@ var AuthModule = class {
968
1021
  body: { refreshToken: token }
969
1022
  }
970
1023
  );
971
- this.client.clearTokens();
972
- this.client.emit("auth:logout");
1024
+ if (this.client.getAuthContextVersion() === authVersion) {
1025
+ this.client.clearTokens();
1026
+ this.client.emit("auth:logout");
1027
+ }
973
1028
  return res;
974
1029
  }
975
1030
  /** Request password reset email */
@@ -2595,6 +2650,19 @@ function useCartCount(options) {
2595
2650
  // src/react/hooks/use-auth.ts
2596
2651
  import { useCallback as useCallback4 } from "react";
2597
2652
  import { useQuery as useQuery12, useMutation as useMutation2, useQueryClient as useQueryClient3 } from "@tanstack/react-query";
2653
+
2654
+ // src/react/utils/clear-customer-queries.ts
2655
+ async function clearCustomerQueries(queryClient, client) {
2656
+ const version = client.getAuthContextVersion();
2657
+ await queryClient.cancelQueries({ queryKey: ["behio"] });
2658
+ if (client.getAuthContextVersion() !== version) return false;
2659
+ for (const query of queryClient.getQueryCache().findAll({ queryKey: ["behio"] })) {
2660
+ query.setState({ data: void 0, dataUpdatedAt: 0, error: null, errorUpdatedAt: 0, status: "pending" });
2661
+ }
2662
+ return true;
2663
+ }
2664
+
2665
+ // src/react/hooks/use-auth.ts
2598
2666
  var CUSTOMER_KEY = ["behio", "customer"];
2599
2667
  var CART_KEY3 = ["behio", "cart"];
2600
2668
  function useAuth() {
@@ -2617,42 +2685,52 @@ function useAuth() {
2617
2685
  },
2618
2686
  [client, storage]
2619
2687
  );
2620
- const clearAuth = useCallback4(() => {
2688
+ const clearAuth = useCallback4(async () => {
2621
2689
  client.clearTokens();
2622
2690
  storage.remove(STORAGE_KEYS.ACCESS_TOKEN);
2623
2691
  storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
2692
+ if (!await clearCustomerQueries(queryClient, client)) return;
2693
+ queryClient.setQueryData([...CART_KEY3], null);
2624
2694
  queryClient.setQueryData([...CUSTOMER_KEY], null);
2625
- queryClient.invalidateQueries({ queryKey: ["behio", "orders"] });
2626
- queryClient.invalidateQueries({ queryKey: ["behio", "addresses"] });
2627
- void refreshBundleQueries(queryClient);
2695
+ if (client.getCartSession()) void queryClient.invalidateQueries({ queryKey: [...CART_KEY3] });
2696
+ void queryClient.invalidateQueries({ queryKey: ["behio"] });
2628
2697
  }, [client, storage, queryClient]);
2629
2698
  const postAuth = useCallback4(async () => {
2699
+ const authVersion = client.getAuthContextVersion();
2700
+ if (!await clearCustomerQueries(queryClient, client)) return;
2630
2701
  await queryClient.invalidateQueries({ queryKey: [...CUSTOMER_KEY] });
2702
+ if (client.getAuthContextVersion() !== authVersion) return;
2631
2703
  if (client.getCartSession()) {
2632
2704
  try {
2633
2705
  await unwrap(client.cart.merge());
2706
+ if (client.getAuthContextVersion() !== authVersion) return;
2634
2707
  client.clearCartSession();
2635
2708
  storage.remove(STORAGE_KEYS.CART_SESSION);
2636
2709
  } catch {
2637
2710
  }
2638
2711
  }
2712
+ if (client.getAuthContextVersion() !== authVersion) return;
2639
2713
  queryClient.invalidateQueries({ queryKey: [...CART_KEY3] });
2640
2714
  void refreshBundleQueries(queryClient);
2641
2715
  }, [client, storage, queryClient]);
2642
2716
  const loginMutation = useMutation2({
2643
2717
  mutationFn: async ({ email, password }) => {
2644
- return unwrap(client.auth.login({ email, password }));
2718
+ const tokens = await unwrap(client.auth.login({ email, password }));
2719
+ return { tokens, authVersion: client.getAuthContextVersion() };
2645
2720
  },
2646
- onSuccess: async (tokens) => {
2721
+ onSuccess: async ({ tokens, authVersion }) => {
2722
+ if (client.getAuthContextVersion() !== authVersion) return;
2647
2723
  persistTokens(tokens);
2648
2724
  await postAuth();
2649
2725
  }
2650
2726
  });
2651
2727
  const registerMutation = useMutation2({
2652
2728
  mutationFn: async (input) => {
2653
- return unwrap(client.auth.register(input));
2729
+ const result = await unwrap(client.auth.register(input));
2730
+ return { result, authVersion: client.getAuthContextVersion() };
2654
2731
  },
2655
- onSuccess: async (result) => {
2732
+ onSuccess: async ({ result, authVersion }) => {
2733
+ if (client.getAuthContextVersion() !== authVersion) return;
2656
2734
  if (result.accessToken && result.refreshToken) {
2657
2735
  persistTokens({ accessToken: result.accessToken, refreshToken: result.refreshToken });
2658
2736
  await postAuth();
@@ -2661,8 +2739,9 @@ function useAuth() {
2661
2739
  });
2662
2740
  const logoutMutation = useMutation2({
2663
2741
  mutationFn: () => unwrap(client.auth.logout()),
2664
- onSettled: () => {
2665
- clearAuth();
2742
+ onMutate: () => client.getAuthContextVersion(),
2743
+ onSettled: (_result, _error, _variables, authVersion) => {
2744
+ if (!client.getAccessToken() || client.getAuthContextVersion() === authVersion) return clearAuth();
2666
2745
  }
2667
2746
  });
2668
2747
  const forgotPasswordMutation = useMutation2({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@behio/storefront-sdk",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "TypeScript SDK for Behio headless e-commerce: core client + React hooks",
5
5
  "author": "Behio",
6
6
  "license": "MIT",