@behio/storefront-sdk 2.0.0 → 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 */
@@ -2456,24 +2511,24 @@ function useCart(options) {
2456
2511
  // Only fetch if we have a cart session or are logged in
2457
2512
  enabled: options?.enabled !== false && (!!client.getCartSession() || !!client.getAccessToken())
2458
2513
  });
2514
+ const cancelCartRead = () => queryClient.cancelQueries({ queryKey: [...CART_KEY] });
2515
+ const acceptCart = async (result) => {
2516
+ await cancelCartRead();
2517
+ queryClient.setQueryData([...CART_KEY], result);
2518
+ void refreshBundleQueries(queryClient);
2519
+ };
2459
2520
  const addMutation = useMutation({
2460
2521
  mutationFn: async ({ productId, quantity }) => {
2461
2522
  return unwrap(client.cart.addItem({ productId, quantity: quantity ?? 1 }));
2462
2523
  },
2463
- onSuccess: (result) => {
2524
+ onMutate: cancelCartRead,
2525
+ onSuccess: async (result) => {
2464
2526
  if (result.newSessionToken) {
2465
2527
  storage.set(STORAGE_KEYS.CART_SESSION, result.newSessionToken);
2466
2528
  }
2467
- queryClient.setQueryData([...CART_KEY], result);
2468
- void refreshBundleQueries(queryClient);
2529
+ await acceptCart(result);
2469
2530
  }
2470
2531
  });
2471
- const cancelCartRead = () => queryClient.cancelQueries({ queryKey: [...CART_KEY] });
2472
- const acceptCart = async (result) => {
2473
- await cancelCartRead();
2474
- queryClient.setQueryData([...CART_KEY], result);
2475
- void refreshBundleQueries(queryClient);
2476
- };
2477
2532
  const updateMutation = useMutation({
2478
2533
  mutationFn: async ({ itemId, quantity }) => {
2479
2534
  return unwrap(client.cart.updateQuantity(itemId, quantity));
@@ -2496,7 +2551,9 @@ function useCart(options) {
2496
2551
  });
2497
2552
  const clearMutation = useMutation({
2498
2553
  mutationFn: () => unwrap(client.cart.clear()),
2499
- onSuccess: () => {
2554
+ onMutate: cancelCartRead,
2555
+ onSuccess: async () => {
2556
+ await cancelCartRead();
2500
2557
  queryClient.setQueryData([...CART_KEY], null);
2501
2558
  client.clearCartSession();
2502
2559
  storage.remove(STORAGE_KEYS.CART_SESSION);
@@ -2505,19 +2562,19 @@ function useCart(options) {
2505
2562
  });
2506
2563
  const applyDiscountMutation = useMutation({
2507
2564
  mutationFn: (code) => unwrap(client.cart.applyDiscount(code)),
2508
- onSuccess: (result) => {
2509
- queryClient.setQueryData([...CART_KEY], result);
2510
- }
2565
+ onMutate: cancelCartRead,
2566
+ onSuccess: acceptCart
2511
2567
  });
2512
2568
  const removeDiscountMutation = useMutation({
2513
2569
  mutationFn: () => unwrap(client.cart.removeDiscount()),
2514
- onSuccess: (result) => {
2515
- queryClient.setQueryData([...CART_KEY], result);
2516
- }
2570
+ onMutate: cancelCartRead,
2571
+ onSuccess: acceptCart
2517
2572
  });
2518
2573
  const mergeMutation = useMutation({
2519
2574
  mutationFn: () => unwrap(client.cart.merge()),
2520
- onSuccess: (result) => {
2575
+ onMutate: cancelCartRead,
2576
+ onSuccess: async (result) => {
2577
+ await cancelCartRead();
2521
2578
  queryClient.setQueryData([...CART_KEY], result);
2522
2579
  client.clearCartSession();
2523
2580
  storage.remove(STORAGE_KEYS.CART_SESSION);
@@ -2593,6 +2650,19 @@ function useCartCount(options) {
2593
2650
  // src/react/hooks/use-auth.ts
2594
2651
  import { useCallback as useCallback4 } from "react";
2595
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
2596
2666
  var CUSTOMER_KEY = ["behio", "customer"];
2597
2667
  var CART_KEY3 = ["behio", "cart"];
2598
2668
  function useAuth() {
@@ -2615,42 +2685,52 @@ function useAuth() {
2615
2685
  },
2616
2686
  [client, storage]
2617
2687
  );
2618
- const clearAuth = useCallback4(() => {
2688
+ const clearAuth = useCallback4(async () => {
2619
2689
  client.clearTokens();
2620
2690
  storage.remove(STORAGE_KEYS.ACCESS_TOKEN);
2621
2691
  storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
2692
+ if (!await clearCustomerQueries(queryClient, client)) return;
2693
+ queryClient.setQueryData([...CART_KEY3], null);
2622
2694
  queryClient.setQueryData([...CUSTOMER_KEY], null);
2623
- queryClient.invalidateQueries({ queryKey: ["behio", "orders"] });
2624
- queryClient.invalidateQueries({ queryKey: ["behio", "addresses"] });
2625
- void refreshBundleQueries(queryClient);
2695
+ if (client.getCartSession()) void queryClient.invalidateQueries({ queryKey: [...CART_KEY3] });
2696
+ void queryClient.invalidateQueries({ queryKey: ["behio"] });
2626
2697
  }, [client, storage, queryClient]);
2627
2698
  const postAuth = useCallback4(async () => {
2699
+ const authVersion = client.getAuthContextVersion();
2700
+ if (!await clearCustomerQueries(queryClient, client)) return;
2628
2701
  await queryClient.invalidateQueries({ queryKey: [...CUSTOMER_KEY] });
2702
+ if (client.getAuthContextVersion() !== authVersion) return;
2629
2703
  if (client.getCartSession()) {
2630
2704
  try {
2631
2705
  await unwrap(client.cart.merge());
2706
+ if (client.getAuthContextVersion() !== authVersion) return;
2632
2707
  client.clearCartSession();
2633
2708
  storage.remove(STORAGE_KEYS.CART_SESSION);
2634
2709
  } catch {
2635
2710
  }
2636
2711
  }
2712
+ if (client.getAuthContextVersion() !== authVersion) return;
2637
2713
  queryClient.invalidateQueries({ queryKey: [...CART_KEY3] });
2638
2714
  void refreshBundleQueries(queryClient);
2639
2715
  }, [client, storage, queryClient]);
2640
2716
  const loginMutation = useMutation2({
2641
2717
  mutationFn: async ({ email, password }) => {
2642
- return unwrap(client.auth.login({ email, password }));
2718
+ const tokens = await unwrap(client.auth.login({ email, password }));
2719
+ return { tokens, authVersion: client.getAuthContextVersion() };
2643
2720
  },
2644
- onSuccess: async (tokens) => {
2721
+ onSuccess: async ({ tokens, authVersion }) => {
2722
+ if (client.getAuthContextVersion() !== authVersion) return;
2645
2723
  persistTokens(tokens);
2646
2724
  await postAuth();
2647
2725
  }
2648
2726
  });
2649
2727
  const registerMutation = useMutation2({
2650
2728
  mutationFn: async (input) => {
2651
- return unwrap(client.auth.register(input));
2729
+ const result = await unwrap(client.auth.register(input));
2730
+ return { result, authVersion: client.getAuthContextVersion() };
2652
2731
  },
2653
- onSuccess: async (result) => {
2732
+ onSuccess: async ({ result, authVersion }) => {
2733
+ if (client.getAuthContextVersion() !== authVersion) return;
2654
2734
  if (result.accessToken && result.refreshToken) {
2655
2735
  persistTokens({ accessToken: result.accessToken, refreshToken: result.refreshToken });
2656
2736
  await postAuth();
@@ -2659,8 +2739,9 @@ function useAuth() {
2659
2739
  });
2660
2740
  const logoutMutation = useMutation2({
2661
2741
  mutationFn: () => unwrap(client.auth.logout()),
2662
- onSettled: () => {
2663
- clearAuth();
2742
+ onMutate: () => client.getAuthContextVersion(),
2743
+ onSettled: (_result, _error, _variables, authVersion) => {
2744
+ if (!client.getAccessToken() || client.getAuthContextVersion() === authVersion) return clearAuth();
2664
2745
  }
2665
2746
  });
2666
2747
  const forgotPasswordMutation = useMutation2({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@behio/storefront-sdk",
3
- "version": "2.0.0",
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",