react-native-iap 15.1.0 → 15.2.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.
Files changed (96) hide show
  1. package/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +171 -114
  2. package/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt +42 -0
  3. package/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt +140 -0
  4. package/ios/HybridRnIap.swift +72 -1
  5. package/lib/module/hooks/useIAP.js +11 -1
  6. package/lib/module/hooks/useIAP.js.map +1 -1
  7. package/lib/module/hooks/useWebhookEvents.js +113 -0
  8. package/lib/module/hooks/useWebhookEvents.js.map +1 -0
  9. package/lib/module/index.js +405 -131
  10. package/lib/module/index.js.map +1 -1
  11. package/lib/module/kit-api.js +161 -0
  12. package/lib/module/kit-api.js.map +1 -0
  13. package/lib/module/types.js +16 -0
  14. package/lib/module/types.js.map +1 -1
  15. package/lib/module/utils/error.js.map +1 -1
  16. package/lib/module/utils/errorMapping.js +6 -0
  17. package/lib/module/utils/errorMapping.js.map +1 -1
  18. package/lib/module/webhook-client.js +164 -0
  19. package/lib/module/webhook-client.js.map +1 -0
  20. package/lib/typescript/plugin/src/withIAP.d.ts +1 -1
  21. package/lib/typescript/src/hooks/useIAP.d.ts +172 -2
  22. package/lib/typescript/src/hooks/useIAP.d.ts.map +1 -1
  23. package/lib/typescript/src/hooks/useWebhookEvents.d.ts +55 -0
  24. package/lib/typescript/src/hooks/useWebhookEvents.d.ts.map +1 -0
  25. package/lib/typescript/src/index.d.ts +283 -129
  26. package/lib/typescript/src/index.d.ts.map +1 -1
  27. package/lib/typescript/src/kit-api.d.ts +54 -0
  28. package/lib/typescript/src/kit-api.d.ts.map +1 -0
  29. package/lib/typescript/src/specs/RnIap.nitro.d.ts +24 -0
  30. package/lib/typescript/src/specs/RnIap.nitro.d.ts.map +1 -1
  31. package/lib/typescript/src/types.d.ts +320 -75
  32. package/lib/typescript/src/types.d.ts.map +1 -1
  33. package/lib/typescript/src/utils/error.d.ts +3 -0
  34. package/lib/typescript/src/utils/error.d.ts.map +1 -1
  35. package/lib/typescript/src/utils/errorMapping.d.ts +6 -0
  36. package/lib/typescript/src/utils/errorMapping.d.ts.map +1 -1
  37. package/lib/typescript/src/webhook-client.d.ts +82 -0
  38. package/lib/typescript/src/webhook-client.d.ts.map +1 -0
  39. package/nitrogen/generated/android/NitroIap+autolinking.cmake +3 -0
  40. package/nitrogen/generated/android/c++/JAdvancedCommerceInfoIOS.hpp +118 -0
  41. package/nitrogen/generated/android/c++/JAdvancedCommerceItemDetailsIOS.hpp +62 -0
  42. package/nitrogen/generated/android/c++/JAdvancedCommerceItemIOS.hpp +78 -0
  43. package/nitrogen/generated/android/c++/JAdvancedCommerceRefundIOS.hpp +62 -0
  44. package/nitrogen/generated/android/c++/JHybridRnIapSpec.cpp +52 -0
  45. package/nitrogen/generated/android/c++/JHybridRnIapSpec.hpp +3 -0
  46. package/nitrogen/generated/android/c++/JPurchase.hpp +11 -0
  47. package/nitrogen/generated/android/c++/JPurchaseIOS.hpp +16 -1
  48. package/nitrogen/generated/android/c++/JRequestPurchaseResult.hpp +11 -0
  49. package/nitrogen/generated/android/c++/JVariant_NullType_AdvancedCommerceInfoIOS.cpp +26 -0
  50. package/nitrogen/generated/android/c++/JVariant_NullType_AdvancedCommerceInfoIOS.hpp +84 -0
  51. package/nitrogen/generated/android/c++/JVariant_NullType_AdvancedCommerceItemDetailsIOS.cpp +26 -0
  52. package/nitrogen/generated/android/c++/JVariant_NullType_AdvancedCommerceItemDetailsIOS.hpp +74 -0
  53. package/nitrogen/generated/android/c++/JVariant_NullType_Array_AdvancedCommerceRefundIOS_.cpp +35 -0
  54. package/nitrogen/generated/android/c++/JVariant_NullType_Array_AdvancedCommerceRefundIOS_.hpp +84 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/AdvancedCommerceInfoIOS.kt +59 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/AdvancedCommerceItemDetailsIOS.kt +38 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/AdvancedCommerceItemIOS.kt +44 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/AdvancedCommerceRefundIOS.kt +38 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/HybridRnIapSpec.kt +22 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/PurchaseIOS.kt +5 -2
  61. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/Variant_NullType_AdvancedCommerceInfoIOS.kt +53 -0
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/Variant_NullType_AdvancedCommerceItemDetailsIOS.kt +53 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/Variant_NullType_Array_AdvancedCommerceRefundIOS_.kt +53 -0
  64. package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Bridge.hpp +166 -0
  65. package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Umbrella.hpp +12 -0
  66. package/nitrogen/generated/ios/c++/HybridRnIapSpecSwift.hpp +32 -0
  67. package/nitrogen/generated/ios/swift/AdvancedCommerceInfoIOS.swift +294 -0
  68. package/nitrogen/generated/ios/swift/AdvancedCommerceItemDetailsIOS.swift +61 -0
  69. package/nitrogen/generated/ios/swift/AdvancedCommerceItemIOS.swift +141 -0
  70. package/nitrogen/generated/ios/swift/AdvancedCommerceRefundIOS.swift +61 -0
  71. package/nitrogen/generated/ios/swift/HybridRnIapSpec.swift +3 -0
  72. package/nitrogen/generated/ios/swift/HybridRnIapSpec_cxx.swift +57 -0
  73. package/nitrogen/generated/ios/swift/PurchaseIOS.swift +39 -2
  74. package/nitrogen/generated/ios/swift/Variant_NullType_AdvancedCommerceInfoIOS.swift +18 -0
  75. package/nitrogen/generated/ios/swift/Variant_NullType_AdvancedCommerceItemDetailsIOS.swift +18 -0
  76. package/nitrogen/generated/ios/swift/Variant_NullType__AdvancedCommerceRefundIOS_.swift +18 -0
  77. package/nitrogen/generated/shared/c++/AdvancedCommerceInfoIOS.hpp +117 -0
  78. package/nitrogen/generated/shared/c++/AdvancedCommerceItemDetailsIOS.hpp +86 -0
  79. package/nitrogen/generated/shared/c++/AdvancedCommerceItemIOS.hpp +99 -0
  80. package/nitrogen/generated/shared/c++/AdvancedCommerceRefundIOS.hpp +86 -0
  81. package/nitrogen/generated/shared/c++/HybridRnIapSpec.cpp +3 -0
  82. package/nitrogen/generated/shared/c++/HybridRnIapSpec.hpp +3 -0
  83. package/nitrogen/generated/shared/c++/PurchaseIOS.hpp +9 -2
  84. package/openiap-versions.json +3 -3
  85. package/package.json +1 -1
  86. package/plugin/build/withIAP.d.ts +1 -1
  87. package/plugin/src/withIAP.ts +1 -1
  88. package/src/hooks/useIAP.ts +185 -2
  89. package/src/hooks/useWebhookEvents.ts +180 -0
  90. package/src/index.ts +440 -130
  91. package/src/kit-api.ts +225 -0
  92. package/src/specs/RnIap.nitro.ts +31 -0
  93. package/src/types.ts +330 -75
  94. package/src/utils/error.ts +3 -0
  95. package/src/utils/errorMapping.ts +12 -0
  96. package/src/webhook-client.ts +312 -0
@@ -34,7 +34,7 @@ type IapPluginProps = {
34
34
  /**
35
35
  * IAPKit API key for purchase verification.
36
36
  * This key will be added to AndroidManifest.xml (as meta-data) and Info.plist.
37
- * Get your API key from https://iapkit.com
37
+ * Get your API key from https://kit.openiap.dev
38
38
  */
39
39
  iapkitApiKey?: string;
40
40
  };
@@ -363,7 +363,7 @@ type IapPluginProps = {
363
363
  /**
364
364
  * IAPKit API key for purchase verification.
365
365
  * This key will be added to AndroidManifest.xml (as meta-data) and Info.plist.
366
- * Get your API key from https://iapkit.com
366
+ * Get your API key from https://kit.openiap.dev
367
367
  */
368
368
  iapkitApiKey?: string;
369
369
  };
@@ -25,6 +25,7 @@ import {
25
25
  showAlternativeBillingDialogAndroid,
26
26
  createAlternativeBillingTokenAndroid,
27
27
  userChoiceBillingListenerAndroid,
28
+ subscriptionBillingIssueListener,
28
29
  isStandardIOS,
29
30
  } from '../';
30
31
 
@@ -63,33 +64,178 @@ type UseIap = {
63
64
  availablePurchases: Purchase[];
64
65
  promotedProductIOS?: Product;
65
66
  activeSubscriptions: ActiveSubscription[];
67
+ /**
68
+ * Complete a purchase transaction. Call after server-side verification to remove it
69
+ * from the queue.
70
+ *
71
+ * @param args.purchase The `Purchase` to finalize.
72
+ * @param args.isConsumable `true` for consumables (consumes the token so the SKU can be
73
+ * re-bought, e.g. coins); `false` (default) for non-consumables and subscriptions.
74
+ * @returns Promise that resolves once the platform finalizes the transaction.
75
+ * @throws When the platform finalize call fails.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * purchaseUpdatedListener(async (purchase) => {
80
+ * if (await verifyOnServer(purchase)) {
81
+ * await finishTransaction({ purchase, isConsumable: false });
82
+ * }
83
+ * });
84
+ * ```
85
+ *
86
+ * @remarks **Critical:** Android purchases must be finalized within 3 days or Google
87
+ * auto-refunds. iOS unfinished transactions replay on every app launch.
88
+ *
89
+ * @see {@link https://www.openiap.dev/docs/apis/finish-transaction}
90
+ */
66
91
  finishTransaction: (args: MutationFinishTransactionArgs) => Promise<void>;
92
+ /**
93
+ * List the user's unfinished purchases — non-consumables, active subscriptions, and any
94
+ * pending transactions not yet finished.
95
+ *
96
+ * @param options Optional `PurchaseOptions`.
97
+ * - iOS: `alsoPublishToEventListenerIOS`, `onlyIncludeActiveItemsIOS`.
98
+ * - Android: `includeSuspendedAndroid` (include subscriptions in a paused/grace state).
99
+ * @returns Promise that resolves when the request is dispatched; results land in the
100
+ * hook's reactive `availablePurchases` state — read from there, don't expect a return value.
101
+ * @throws When the platform query fails.
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * const { availablePurchases, getAvailablePurchases, finishTransaction } = useIAP();
106
+ *
107
+ * useEffect(() => {
108
+ * void getAvailablePurchases();
109
+ * }, [getAvailablePurchases]);
110
+ *
111
+ * useEffect(() => {
112
+ * for (const p of availablePurchases) {
113
+ * void verifyOnServer(p).then((ok) => {
114
+ * if (ok) finishTransaction({ purchase: p, isConsumable: false });
115
+ * });
116
+ * }
117
+ * }, [availablePurchases, finishTransaction]);
118
+ * ```
119
+ *
120
+ * @see {@link https://www.openiap.dev/docs/apis/get-available-purchases}
121
+ */
67
122
  getAvailablePurchases: (options?: PurchaseOptions) => Promise<void>;
123
+ /**
124
+ * Retrieve products or subscriptions from the store by SKU.
125
+ *
126
+ * @param params `ProductRequest` — `skus` (string[]) and optional `type`
127
+ * (`'in-app' | 'subs' | 'all'`, defaults to `'in-app'`).
128
+ * @returns Promise that resolves when the request is dispatched; results land in the
129
+ * hook's reactive `products` / `subscriptions` state — read from there, don't expect a return value.
130
+ * @throws When the store rejects the request (empty `skus`, not connected,
131
+ * network/store error). Unknown SKUs are simply omitted from the result, not thrown.
132
+ *
133
+ * @example
134
+ * ```ts
135
+ * const { products, fetchProducts } = useIAP();
136
+ *
137
+ * useEffect(() => {
138
+ * void fetchProducts({
139
+ * skus: ['com.app.coins_100', 'com.app.premium'],
140
+ * type: 'in-app',
141
+ * });
142
+ * }, [fetchProducts]);
143
+ *
144
+ * // Render `products` directly from hook state.
145
+ * ```
146
+ *
147
+ * @remarks This is a regular promise-based call. Don't confuse with `request*` APIs
148
+ * (`requestPurchase`), which are event-based.
149
+ *
150
+ * @see {@link https://www.openiap.dev/docs/apis/fetch-products}
151
+ */
68
152
  fetchProducts: (params: {
69
153
  skus: string[];
70
154
  type?: ProductQueryType | null;
71
155
  }) => Promise<void>;
156
+ /**
157
+ * Initiate a purchase or subscription flow. The result is delivered through
158
+ * `purchaseUpdatedListener` — NOT the return value.
159
+ *
160
+ * @param props `RequestPurchaseProps`, discriminated by `type`:
161
+ * - `type: 'in-app'` — pass `request.apple.sku` (iOS) and/or `request.google.skus` (Android).
162
+ * - `type: 'subs'` — same shape, plus `request.google.subscriptionOffers: [{ sku, offerToken }]`.
163
+ * @returns Promise that resolves when the request is dispatched; results land in the
164
+ * hook's `onPurchaseSuccess` / `onPurchaseError` callbacks.
165
+ * @throws Synchronous rejection from the store (e.g. `E_NOT_PREPARED`, validation failure).
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * await requestPurchase({
170
+ * request: {
171
+ * apple: { sku: 'com.app.premium' },
172
+ * google: { skus: ['com.app.premium'] },
173
+ * },
174
+ * type: 'in-app',
175
+ * });
176
+ * ```
177
+ *
178
+ * @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} /
179
+ * {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`.
180
+ *
181
+ * @see {@link https://www.openiap.dev/docs/apis/request-purchase}
182
+ */
72
183
  requestPurchase: (params: RequestPurchaseProps) => Promise<void>;
73
184
  /**
74
185
  * @deprecated Use `verifyPurchase` instead. This function will be removed in a future version.
186
+ *
187
+ * @see {@link https://www.openiap.dev/docs/apis/validate-receipt}
75
188
  */
76
189
  validateReceipt: (
77
190
  options: VerifyPurchaseProps,
78
191
  ) => Promise<VerifyPurchaseResult>;
79
- /** Verify purchase with the configured providers */
192
+ /**
193
+ * Verify a purchase against your own backend (returns isValid + raw store metadata).
194
+ *
195
+ * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase}
196
+ */
80
197
  verifyPurchase: (
81
198
  options: VerifyPurchaseProps,
82
199
  ) => Promise<VerifyPurchaseResult>;
83
- /** Verify purchase with a specific provider (e.g., IAPKit) */
200
+ /**
201
+ * Verify via a managed provider — currently only `iapkit` (IAPKit). The PurchaseVerificationProvider enum exposes no other provider literal today.
202
+ *
203
+ * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider}
204
+ */
84
205
  verifyPurchaseWithProvider: (
85
206
  options: VerifyPurchaseWithProviderProps,
86
207
  ) => Promise<VerifyPurchaseWithProviderResult>;
208
+ /**
209
+ * Restore non-consumable and active subscription purchases.
210
+ *
211
+ * @see {@link https://www.openiap.dev/docs/apis/restore-purchases}
212
+ */
87
213
  restorePurchases: (options?: PurchaseOptions) => Promise<void>;
214
+ /**
215
+ * Read the App Store-promoted product, if any.
216
+ *
217
+ * @see {@link https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios}
218
+ */
88
219
  getPromotedProductIOS: () => Promise<Product | null>;
220
+ /**
221
+ * Buy the currently promoted product.
222
+ *
223
+ * @see {@link https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios}
224
+ */
89
225
  requestPurchaseOnPromotedProductIOS: () => Promise<boolean>;
226
+ /**
227
+ * Get details of all currently active subscriptions.
228
+ *
229
+ * @see {@link https://www.openiap.dev/docs/apis/get-active-subscriptions}
230
+ */
90
231
  getActiveSubscriptions: (
91
232
  subscriptionIds?: string[],
92
233
  ) => Promise<ActiveSubscription[]>;
234
+ /**
235
+ * Check whether the user has any active subscription.
236
+ *
237
+ * @see {@link https://www.openiap.dev/docs/apis/has-active-subscriptions}
238
+ */
93
239
  hasActiveSubscriptions: (subscriptionIds?: string[]) => Promise<boolean>;
94
240
  /**
95
241
  * Manually retry the store connection.
@@ -98,8 +244,23 @@ type UseIap = {
98
244
  */
99
245
  reconnect: () => Promise<boolean>;
100
246
  // Alternative billing (Android)
247
+ /**
248
+ * Check whether alternative billing is available for the user.
249
+ *
250
+ * @see {@link https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android}
251
+ */
101
252
  checkAlternativeBillingAvailabilityAndroid?: () => Promise<boolean>;
253
+ /**
254
+ * Display Google's alternative billing information dialog.
255
+ *
256
+ * @see {@link https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android}
257
+ */
102
258
  showAlternativeBillingDialogAndroid?: () => Promise<boolean>;
259
+ /**
260
+ * Create a reporting token for an alternative billing flow.
261
+ *
262
+ * @see {@link https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android}
263
+ */
103
264
  createAlternativeBillingTokenAndroid?: (
104
265
  sku?: string,
105
266
  ) => Promise<string | null>;
@@ -112,6 +273,16 @@ export interface UseIapOptions {
112
273
  onError?: (error: Error) => void;
113
274
  onPromotedProductIOS?: (product: Product) => void;
114
275
  onUserChoiceBillingAndroid?: (details: UserChoiceBillingDetails) => void;
276
+ /**
277
+ * Fires when an active subscription enters a billing-issue state
278
+ * (StoreKit 2 Message.billingIssue on iOS 18+, Purchase.isSuspended on
279
+ * Play Billing 8.1+). Not invoked on Meta Horizon.
280
+ *
281
+ * Recommended: call deepLinkToSubscriptions on the returned purchase so
282
+ * the user can update their payment method in the platform subscription
283
+ * center.
284
+ */
285
+ onSubscriptionBillingIssue?: (purchase: Purchase) => void;
115
286
  /**
116
287
  * @deprecated Use enableBillingProgramAndroid instead.
117
288
  * - 'user-choice' → 'user-choice-billing'
@@ -178,6 +349,7 @@ export function useIAP(options?: UseIapOptions): UseIap {
178
349
  purchaseError?: EventSubscription;
179
350
  promotedProductIOS?: EventSubscription;
180
351
  userChoiceBillingAndroid?: EventSubscription;
352
+ subscriptionBillingIssue?: EventSubscription;
181
353
  }>({});
182
354
 
183
355
  // Track if component is mounted to prevent listener leaks on early unmount
@@ -463,6 +635,15 @@ export function useIAP(options?: UseIapOptions): UseIap {
463
635
  }
464
636
  });
465
637
  }
638
+
639
+ // Always attach so callers that supply `onSubscriptionBillingIssue` later
640
+ // (after the hook has already set up listeners) still receive events.
641
+ if (!subscriptionsRef.current.subscriptionBillingIssue) {
642
+ subscriptionsRef.current.subscriptionBillingIssue =
643
+ subscriptionBillingIssueListener((purchase: Purchase) => {
644
+ optionsRef.current?.onSubscriptionBillingIssue?.(purchase);
645
+ });
646
+ }
466
647
  }, [getActiveSubscriptionsInternal, getAvailablePurchasesInternal]);
467
648
 
468
649
  // Shared helper: clean up all listeners
@@ -471,10 +652,12 @@ export function useIAP(options?: UseIapOptions): UseIap {
471
652
  subscriptionsRef.current.purchaseError?.remove();
472
653
  subscriptionsRef.current.promotedProductIOS?.remove();
473
654
  subscriptionsRef.current.userChoiceBillingAndroid?.remove();
655
+ subscriptionsRef.current.subscriptionBillingIssue?.remove();
474
656
  subscriptionsRef.current.purchaseUpdate = undefined;
475
657
  subscriptionsRef.current.purchaseError = undefined;
476
658
  subscriptionsRef.current.promotedProductIOS = undefined;
477
659
  subscriptionsRef.current.userChoiceBillingAndroid = undefined;
660
+ subscriptionsRef.current.subscriptionBillingIssue = undefined;
478
661
  }, []);
479
662
 
480
663
  const initIapWithSubscriptions = useCallback(async (): Promise<void> => {
@@ -0,0 +1,180 @@
1
+ import {useEffect, useRef, useState} from 'react';
2
+
3
+ import {
4
+ connectWebhookStream,
5
+ type WebhookEventPayload,
6
+ type WebhookEventStream,
7
+ type WebhookListener,
8
+ type WebhookListenerError,
9
+ } from '../webhook-client';
10
+
11
+ export type UseWebhookEventsOptions = {
12
+ /**
13
+ * kit project API key — same value used for receipt verification.
14
+ * Must be non-empty to start the stream; pass `null`/`undefined` to
15
+ * disable the listener (e.g. before the user is logged in).
16
+ */
17
+ apiKey: string | null | undefined;
18
+ /**
19
+ * Override the kit base URL. Defaults to https://kit.openiap.dev.
20
+ */
21
+ baseUrl?: string;
22
+ /**
23
+ * Optional EventSource factory. Required on React Native because RN
24
+ * does not ship a global EventSource — pass an instance from
25
+ * `react-native-sse` (or any compatible polyfill).
26
+ */
27
+ eventSourceFactory?: (
28
+ url: string,
29
+ headers: Record<string, string>,
30
+ ) => WebhookEventStream;
31
+ /**
32
+ * Maximum number of events to retain in the in-memory ring buffer
33
+ * surfaced as `events`. Older entries are discarded. Defaults to 50.
34
+ * Set 0 to opt out of the buffer entirely (consume only via
35
+ * `onEvent`).
36
+ */
37
+ bufferSize?: number;
38
+ /**
39
+ * Called for every received event in addition to being appended to
40
+ * the buffer. Useful for side effects (toast, analytics, granting
41
+ * entitlement). Called with the latest stable callback identity.
42
+ */
43
+ onEvent?: (event: WebhookEventPayload) => void;
44
+ /**
45
+ * Called when the stream surfaces a transport / parse error.
46
+ * EventSource auto-reconnects regardless of this hook — this is
47
+ * primarily for telemetry + UI surfacing.
48
+ */
49
+ onError?: (error: WebhookListenerError) => void;
50
+ };
51
+
52
+ export type UseWebhookEventsResult = {
53
+ /** Most recent N events (most-recent-first). Capped at bufferSize. */
54
+ events: WebhookEventPayload[];
55
+ /** Last error reported by the underlying stream. Null when healthy. */
56
+ lastError: WebhookListenerError | null;
57
+ /**
58
+ * True once the first webhook event has been received from the
59
+ * stream. Remains false if the connection is open but idle (the
60
+ * underlying SSE bridge doesn't surface a "stream opened"
61
+ * lifecycle event we can hook into; isConnected is therefore an
62
+ * activity indicator, not a raw socket-state flag). Reset to
63
+ * false on cleanup / apiKey change.
64
+ */
65
+ isConnected: boolean;
66
+ };
67
+
68
+ // React hook wrapping the SSE webhook stream. Lifecycle:
69
+ // - opens on mount (once `apiKey` is non-empty),
70
+ // - closes on unmount,
71
+ // - reconnects automatically when EventSource raises a transport
72
+ // error (the underlying client auto-reconnects via the EventSource
73
+ // spec; this hook just surfaces the error and re-renders).
74
+ //
75
+ // Why a hook: openiap's UX guidance is that consumers consume webhook
76
+ // events from React state (granting entitlement, refreshing the
77
+ // subscription view) rather than via an imperative listener. The
78
+ // hook's `events` buffer + `onEvent` callback cover both styles.
79
+ export function useWebhookEvents({
80
+ apiKey,
81
+ baseUrl,
82
+ eventSourceFactory,
83
+ bufferSize = 50,
84
+ onEvent,
85
+ onError,
86
+ }: UseWebhookEventsOptions): UseWebhookEventsResult {
87
+ const [events, setEvents] = useState<WebhookEventPayload[]>([]);
88
+ const [lastError, setLastError] = useState<WebhookListenerError | null>(null);
89
+ const [isConnected, setIsConnected] = useState(false);
90
+
91
+ // Stash callbacks in refs so reconnects don't fire on every render.
92
+ // The underlying SSE connection should only restart when `apiKey` /
93
+ // `baseUrl` change. `eventSourceFactory` is held in a ref too so
94
+ // anonymous-function callers don't tear down the connection every
95
+ // render (a common React pitfall — was previously documented as a
96
+ // caller-side constraint, now enforced by the hook). `bufferSize`
97
+ // is also a ref so adjusting the buffer cap from the host component
98
+ // doesn't tear down the stream and lose in-flight events.
99
+ const onEventRef = useRef(onEvent);
100
+ const onErrorRef = useRef(onError);
101
+ const eventSourceFactoryRef = useRef(eventSourceFactory);
102
+ const bufferSizeRef = useRef(bufferSize);
103
+ onEventRef.current = onEvent;
104
+ onErrorRef.current = onError;
105
+ eventSourceFactoryRef.current = eventSourceFactory;
106
+ bufferSizeRef.current = bufferSize;
107
+
108
+ // Trim the visible buffer immediately when bufferSize is lowered
109
+ // mid-stream. The ref-based update would otherwise only take
110
+ // effect on the next event.
111
+ useEffect(() => {
112
+ setEvents((prev) => (bufferSize > 0 ? prev.slice(0, bufferSize) : []));
113
+ }, [bufferSize]);
114
+
115
+ useEffect(() => {
116
+ // Fresh stream → fresh state. Resetting events + lastError on
117
+ // (re)connect prevents a stale payload from the previous
118
+ // apiKey/baseUrl from briefly leaking into the new context.
119
+ setEvents([]);
120
+ setLastError(null);
121
+
122
+ if (!apiKey) {
123
+ return;
124
+ }
125
+
126
+ let listener: WebhookListener | null = null;
127
+ let mounted = true;
128
+
129
+ try {
130
+ listener = connectWebhookStream({
131
+ apiKey,
132
+ baseUrl,
133
+ eventSourceFactory: eventSourceFactoryRef.current,
134
+ onEvent: (event) => {
135
+ if (!mounted) {
136
+ return;
137
+ }
138
+ setIsConnected(true);
139
+ const cap = bufferSizeRef.current;
140
+ if (cap > 0) {
141
+ setEvents((prev) => [event, ...prev].slice(0, cap));
142
+ }
143
+ onEventRef.current?.(event);
144
+ },
145
+ onError: (error) => {
146
+ if (!mounted) {
147
+ return;
148
+ }
149
+ setLastError(error);
150
+ onErrorRef.current?.(error);
151
+ },
152
+ });
153
+ } catch (error) {
154
+ const wrapped: WebhookListenerError = {
155
+ code: 'TRANSPORT_ERROR',
156
+ message:
157
+ error instanceof Error
158
+ ? error.message
159
+ : 'Failed to open webhook stream',
160
+ cause: error,
161
+ };
162
+ setLastError(wrapped);
163
+ onErrorRef.current?.(wrapped);
164
+ }
165
+
166
+ return () => {
167
+ mounted = false;
168
+ listener?.close();
169
+ setIsConnected(false);
170
+ };
171
+ // `eventSourceFactory` deliberately omitted from deps — held in a
172
+ // ref above so anonymous-function callers don't trigger reconnects
173
+ // on every render. The connection is only re-opened when apiKey or
174
+ // baseUrl changes; a runtime factory swap is picked up on that
175
+ // next reconnect via the ref.
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ }, [apiKey, baseUrl]);
178
+
179
+ return {events, lastError, isConnected};
180
+ }