react-native-iap 15.5.4 → 15.6.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 (77) hide show
  1. package/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +163 -15
  2. package/android/src/main/java/com/margelo/nitro/iap/PendingEventBuffer.kt +86 -0
  3. package/android/src/main/java/com/margelo/nitro/iap/RnIapLog.kt +13 -0
  4. package/ios/HybridRnIap.swift +31 -4
  5. package/ios/RnIapHelper.swift +3 -2
  6. package/ios/RnIapLog.swift +14 -0
  7. package/lib/module/hooks/useIAP.js +8 -2
  8. package/lib/module/hooks/useIAP.js.map +1 -1
  9. package/lib/module/index.js +105 -30
  10. package/lib/module/index.js.map +1 -1
  11. package/lib/module/index.kepler.js +72 -5
  12. package/lib/module/index.kepler.js.map +1 -1
  13. package/lib/module/kit-api.js +78 -3
  14. package/lib/module/kit-api.js.map +1 -1
  15. package/lib/module/types.js +29 -25
  16. package/lib/module/types.js.map +1 -1
  17. package/lib/module/utils/deprecation.js +15 -0
  18. package/lib/module/utils/deprecation.js.map +1 -0
  19. package/lib/module/utils/platform-request.js +28 -0
  20. package/lib/module/utils/platform-request.js.map +1 -0
  21. package/lib/module/utils/type-bridge.js +9 -4
  22. package/lib/module/utils/type-bridge.js.map +1 -1
  23. package/lib/module/vega-adapter.js +34 -22
  24. package/lib/module/vega-adapter.js.map +1 -1
  25. package/lib/typescript/src/hooks/useIAP.d.ts +16 -2
  26. package/lib/typescript/src/hooks/useIAP.d.ts.map +1 -1
  27. package/lib/typescript/src/index.d.ts +56 -12
  28. package/lib/typescript/src/index.d.ts.map +1 -1
  29. package/lib/typescript/src/index.kepler.d.ts +50 -4
  30. package/lib/typescript/src/index.kepler.d.ts.map +1 -1
  31. package/lib/typescript/src/kit-api.d.ts +18 -8
  32. package/lib/typescript/src/kit-api.d.ts.map +1 -1
  33. package/lib/typescript/src/specs/RnIap.nitro.d.ts +32 -6
  34. package/lib/typescript/src/specs/RnIap.nitro.d.ts.map +1 -1
  35. package/lib/typescript/src/types.d.ts +119 -175
  36. package/lib/typescript/src/types.d.ts.map +1 -1
  37. package/lib/typescript/src/utils/deprecation.d.ts +3 -0
  38. package/lib/typescript/src/utils/deprecation.d.ts.map +1 -0
  39. package/lib/typescript/src/utils/platform-request.d.ts +12 -0
  40. package/lib/typescript/src/utils/platform-request.d.ts.map +1 -0
  41. package/lib/typescript/src/utils/type-bridge.d.ts.map +1 -1
  42. package/lib/typescript/src/vega-adapter.d.ts +1 -0
  43. package/lib/typescript/src/vega-adapter.d.ts.map +1 -1
  44. package/nitrogen/generated/android/c++/JHybridRnIapSpec.cpp +16 -0
  45. package/nitrogen/generated/android/c++/JHybridRnIapSpec.hpp +1 -0
  46. package/nitrogen/generated/android/c++/JNitroPurchase.hpp +5 -1
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/HybridRnIapSpec.kt +4 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroPurchase.kt +7 -2
  49. package/nitrogen/generated/ios/c++/HybridRnIapSpecSwift.hpp +8 -0
  50. package/nitrogen/generated/ios/swift/HybridRnIapSpec.swift +1 -0
  51. package/nitrogen/generated/ios/swift/HybridRnIapSpec_cxx.swift +19 -0
  52. package/nitrogen/generated/ios/swift/NitroPurchase.swift +39 -2
  53. package/nitrogen/generated/shared/c++/HybridRnIapSpec.cpp +1 -0
  54. package/nitrogen/generated/shared/c++/HybridRnIapSpec.hpp +1 -0
  55. package/nitrogen/generated/shared/c++/NitroPurchase.hpp +5 -1
  56. package/openiap-versions.json +3 -3
  57. package/package.json +1 -1
  58. package/src/hooks/useIAP.ts +22 -2
  59. package/src/index.kepler.ts +86 -8
  60. package/src/index.ts +139 -47
  61. package/src/kit-api.ts +136 -13
  62. package/src/specs/RnIap.nitro.ts +33 -6
  63. package/src/types.ts +130 -203
  64. package/src/utils/deprecation.ts +16 -0
  65. package/src/utils/platform-request.ts +37 -0
  66. package/src/utils/type-bridge.ts +20 -4
  67. package/src/vega-adapter.ts +50 -24
  68. package/lib/module/hooks/useWebhookEvents.js +0 -113
  69. package/lib/module/hooks/useWebhookEvents.js.map +0 -1
  70. package/lib/module/webhook-client.js +0 -164
  71. package/lib/module/webhook-client.js.map +0 -1
  72. package/lib/typescript/src/hooks/useWebhookEvents.d.ts +0 -55
  73. package/lib/typescript/src/hooks/useWebhookEvents.d.ts.map +0 -1
  74. package/lib/typescript/src/webhook-client.d.ts +0 -82
  75. package/lib/typescript/src/webhook-client.d.ts.map +0 -1
  76. package/src/hooks/useWebhookEvents.ts +0 -180
  77. package/src/webhook-client.ts +0 -312
@@ -1,82 +0,0 @@
1
- export type WebhookEventType = "SubscriptionStarted" | "SubscriptionRenewed" | "SubscriptionExpired" | "SubscriptionInGracePeriod" | "SubscriptionInBillingRetry" | "SubscriptionRecovered" | "SubscriptionCanceled" | "SubscriptionUncanceled" | "SubscriptionRevoked" | "SubscriptionPriceChange" | "SubscriptionProductChanged" | "SubscriptionPaused" | "SubscriptionResumed" | "PurchaseRefunded" | "PurchaseConsumptionRequest" | "TestNotification";
2
- export declare const WEBHOOK_EVENT_TYPES: readonly ["SubscriptionStarted", "SubscriptionRenewed", "SubscriptionExpired", "SubscriptionInGracePeriod", "SubscriptionInBillingRetry", "SubscriptionRecovered", "SubscriptionCanceled", "SubscriptionUncanceled", "SubscriptionRevoked", "SubscriptionPriceChange", "SubscriptionProductChanged", "SubscriptionPaused", "SubscriptionResumed", "PurchaseRefunded", "PurchaseConsumptionRequest", "TestNotification"];
3
- export type WebhookEventPayload = {
4
- id: string;
5
- type: WebhookEventType;
6
- source: string;
7
- platform: "IOS" | "Android";
8
- environment: "Production" | "Sandbox" | "Xcode";
9
- projectId: string;
10
- occurredAt: number;
11
- receivedAt: number;
12
- purchaseToken?: string;
13
- productId?: string;
14
- subscriptionState?: string;
15
- expiresAt?: number;
16
- renewsAt?: number;
17
- cancellationReason?: string;
18
- currency?: string;
19
- priceAmountMicros?: number;
20
- rawSignedPayload?: string;
21
- };
22
- export type WebhookListenerOptions = {
23
- /**
24
- * Project API key. Embedded in the URL path because Apple ASN
25
- * registration cannot send custom headers; the same path is reused
26
- * here for symmetry.
27
- */
28
- apiKey: string;
29
- /**
30
- * Override the kit base URL. Defaults to https://kit.openiap.dev.
31
- * In tests, point this at a local server.
32
- */
33
- baseUrl?: string;
34
- /** Called on every successfully-parsed webhook event. */
35
- onEvent: (event: WebhookEventPayload) => void;
36
- /**
37
- * Called on transport errors. The connection auto-reconnects
38
- * unconditionally; this callback exists for telemetry / surfacing
39
- * to the host UI.
40
- */
41
- onError?: (error: WebhookListenerError) => void;
42
- /**
43
- * Optional injection of an EventSource constructor. Lets RN /
44
- * Expo plug in `react-native-event-source` when running on a JS
45
- * runtime that lacks the global, or vitest plug in a stub.
46
- */
47
- eventSourceFactory?: (url: string, headers: Record<string, string>) => WebhookEventStream;
48
- };
49
- export interface WebhookEventStream {
50
- close(): void;
51
- onmessage: ((event: {
52
- data: string;
53
- lastEventId?: string;
54
- }) => void) | null;
55
- onerror: ((error: unknown) => void) | null;
56
- addEventListener?: (type: string, listener: (event: {
57
- data: string;
58
- lastEventId?: string;
59
- }) => void) => void;
60
- }
61
- export type WebhookListener = {
62
- /** Tear down the connection and stop receiving events. */
63
- close(): void;
64
- };
65
- export type WebhookListenerError = {
66
- code: "TRANSPORT_ERROR" | "PARSE_ERROR" | "MALFORMED_EVENT" | "NO_EVENTSOURCE";
67
- message: string;
68
- cause?: unknown;
69
- };
70
- export declare function connectWebhookStream(options: WebhookListenerOptions): WebhookListener;
71
- export type ParsedEventResult = {
72
- kind: "ok";
73
- event: WebhookEventPayload;
74
- } | {
75
- kind: "skip";
76
- reason: "heartbeat" | "stream-control";
77
- } | {
78
- kind: "error";
79
- message: string;
80
- };
81
- export declare function parseWebhookEventData(raw: string): ParsedEventResult;
82
- //# sourceMappingURL=webhook-client.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"webhook-client.d.ts","sourceRoot":"","sources":["../../../src/webhook-client.ts"],"names":[],"mappings":"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"}
@@ -1,180 +0,0 @@
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
- }
@@ -1,312 +0,0 @@
1
- // Transport-agnostic webhook client for the openiap kit SSE stream
2
- // (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS
3
- // wrappers (react-native-iap, expo-iap) but written without React or
4
- // React-Native imports so it can also run in plain Node, browser, or
5
- // any other JS runtime.
6
- //
7
- // The wire format is documented in `packages/kit/server/api/v1/webhooks.ts`
8
- // and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`.
9
- //
10
- // Parser logic is split out from the connection so it can be unit-
11
- // tested without a live server. See `webhook-client.test.ts`.
12
-
13
- export type WebhookEventType =
14
- | "SubscriptionStarted"
15
- | "SubscriptionRenewed"
16
- | "SubscriptionExpired"
17
- | "SubscriptionInGracePeriod"
18
- | "SubscriptionInBillingRetry"
19
- | "SubscriptionRecovered"
20
- | "SubscriptionCanceled"
21
- | "SubscriptionUncanceled"
22
- | "SubscriptionRevoked"
23
- | "SubscriptionPriceChange"
24
- | "SubscriptionProductChanged"
25
- | "SubscriptionPaused"
26
- | "SubscriptionResumed"
27
- | "PurchaseRefunded"
28
- | "PurchaseConsumptionRequest"
29
- | "TestNotification";
30
-
31
- export const WEBHOOK_EVENT_TYPES = [
32
- "SubscriptionStarted",
33
- "SubscriptionRenewed",
34
- "SubscriptionExpired",
35
- "SubscriptionInGracePeriod",
36
- "SubscriptionInBillingRetry",
37
- "SubscriptionRecovered",
38
- "SubscriptionCanceled",
39
- "SubscriptionUncanceled",
40
- "SubscriptionRevoked",
41
- "SubscriptionPriceChange",
42
- "SubscriptionProductChanged",
43
- "SubscriptionPaused",
44
- "SubscriptionResumed",
45
- "PurchaseRefunded",
46
- "PurchaseConsumptionRequest",
47
- "TestNotification",
48
- ] as const satisfies readonly WebhookEventType[];
49
-
50
- export type WebhookEventPayload = {
51
- id: string;
52
- type: WebhookEventType;
53
- source: string;
54
- platform: "IOS" | "Android";
55
- environment: "Production" | "Sandbox" | "Xcode";
56
- projectId: string;
57
- occurredAt: number;
58
- receivedAt: number;
59
- // Optional because TestNotification frames carry no transaction;
60
- // every other event type populates this.
61
- purchaseToken?: string;
62
- productId?: string;
63
- subscriptionState?: string;
64
- expiresAt?: number;
65
- renewsAt?: number;
66
- cancellationReason?: string;
67
- currency?: string;
68
- priceAmountMicros?: number;
69
- rawSignedPayload?: string;
70
- };
71
-
72
- export type WebhookListenerOptions = {
73
- /**
74
- * Project API key. Embedded in the URL path because Apple ASN
75
- * registration cannot send custom headers; the same path is reused
76
- * here for symmetry.
77
- */
78
- apiKey: string;
79
- /**
80
- * Override the kit base URL. Defaults to https://kit.openiap.dev.
81
- * In tests, point this at a local server.
82
- */
83
- baseUrl?: string;
84
- /** Called on every successfully-parsed webhook event. */
85
- onEvent: (event: WebhookEventPayload) => void;
86
- /**
87
- * Called on transport errors. The connection auto-reconnects
88
- * unconditionally; this callback exists for telemetry / surfacing
89
- * to the host UI.
90
- */
91
- onError?: (error: WebhookListenerError) => void;
92
- /**
93
- * Optional injection of an EventSource constructor. Lets RN /
94
- * Expo plug in `react-native-event-source` when running on a JS
95
- * runtime that lacks the global, or vitest plug in a stub.
96
- */
97
- eventSourceFactory?: (
98
- url: string,
99
- headers: Record<string, string>,
100
- ) => WebhookEventStream;
101
- };
102
-
103
- export interface WebhookEventStream {
104
- close(): void;
105
- onmessage: ((event: { data: string; lastEventId?: string }) => void) | null;
106
- onerror: ((error: unknown) => void) | null;
107
- addEventListener?: (
108
- type: string,
109
- listener: (event: { data: string; lastEventId?: string }) => void,
110
- ) => void;
111
- }
112
-
113
- export type WebhookListener = {
114
- /** Tear down the connection and stop receiving events. */
115
- close(): void;
116
- };
117
-
118
- export type WebhookListenerError = {
119
- code:
120
- | "TRANSPORT_ERROR"
121
- | "PARSE_ERROR"
122
- | "MALFORMED_EVENT"
123
- | "NO_EVENTSOURCE";
124
- message: string;
125
- cause?: unknown;
126
- };
127
-
128
- const DEFAULT_BASE_URL = "https://kit.openiap.dev";
129
-
130
- export function connectWebhookStream(
131
- options: WebhookListenerOptions,
132
- ): WebhookListener {
133
- const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
134
- const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`;
135
-
136
- const factory = options.eventSourceFactory ?? defaultEventSourceFactory;
137
- let stream: WebhookEventStream;
138
- try {
139
- stream = factory(url, {});
140
- } catch (error) {
141
- options.onError?.({
142
- code: "NO_EVENTSOURCE",
143
- message:
144
- error instanceof Error
145
- ? error.message
146
- : "EventSource constructor unavailable in this runtime",
147
- cause: error,
148
- });
149
- return { close: () => {} };
150
- }
151
-
152
- const seenIds = new Set<string>();
153
- const seenOrder: string[] = [];
154
- const markSeen = (id: string): boolean => {
155
- if (seenIds.has(id)) {
156
- return true;
157
- }
158
- seenIds.add(id);
159
- seenOrder.push(id);
160
- if (seenOrder.length > 1024) {
161
- const evicted = seenOrder.shift();
162
- if (evicted !== undefined) {
163
- seenIds.delete(evicted);
164
- }
165
- }
166
- return false;
167
- };
168
-
169
- const handleData = (raw: string) => {
170
- const parsed = parseWebhookEventData(raw);
171
- if (parsed.kind === "error") {
172
- options.onError?.({
173
- code: "PARSE_ERROR",
174
- message: parsed.message,
175
- });
176
- return;
177
- }
178
- if (parsed.kind === "skip") {
179
- return;
180
- }
181
- if (markSeen(parsed.event.id)) {
182
- return;
183
- }
184
- options.onEvent(parsed.event);
185
- };
186
-
187
- if (typeof stream.addEventListener === "function") {
188
- stream.addEventListener("message", (event) => handleData(event.data));
189
- // WHATWG EventSource dispatches frames with `event: Foo` only to
190
- // listeners registered for `Foo`, not to `message` / `onmessage`.
191
- // Kit emits webhook frames as typed SSE events, so subscribe to
192
- // every known webhook type and keep `message` for older servers or
193
- // polyfills that collapse typed frames into the generic channel.
194
- for (const eventType of WEBHOOK_EVENT_TYPES) {
195
- stream.addEventListener(eventType, (event) => handleData(event.data));
196
- }
197
- } else {
198
- stream.onmessage = (event) => handleData(event.data);
199
- }
200
-
201
- stream.onerror = (error) => {
202
- options.onError?.({
203
- code: "TRANSPORT_ERROR",
204
- message: "SSE transport error (auto-reconnecting)",
205
- cause: error,
206
- });
207
- };
208
-
209
- return {
210
- close: () => {
211
- try {
212
- stream.close();
213
- } catch {
214
- // Closing an already-closed EventSource is a no-op in browsers
215
- // but throws in some polyfills.
216
- }
217
- },
218
- };
219
- }
220
-
221
- // ---------------------------------------------------------------------------
222
- // Pure helpers (exported for testing).
223
- // ---------------------------------------------------------------------------
224
-
225
- export type ParsedEventResult =
226
- | { kind: "ok"; event: WebhookEventPayload }
227
- | { kind: "skip"; reason: "heartbeat" | "stream-control" }
228
- | { kind: "error"; message: string };
229
-
230
- export function parseWebhookEventData(raw: string): ParsedEventResult {
231
- if (!raw) {
232
- return { kind: "skip", reason: "heartbeat" };
233
- }
234
-
235
- let parsed: unknown;
236
- try {
237
- parsed = JSON.parse(raw);
238
- } catch (error) {
239
- return {
240
- kind: "error",
241
- message:
242
- error instanceof Error
243
- ? `Failed to parse SSE payload: ${error.message}`
244
- : "Failed to parse SSE payload",
245
- };
246
- }
247
-
248
- if (
249
- typeof parsed !== "object" ||
250
- parsed === null ||
251
- !("type" in parsed) ||
252
- typeof (parsed as Record<string, unknown>).type !== "string"
253
- ) {
254
- // Stream-control messages (the `ready`/`stream-error` envelopes
255
- // emitted by the kit server) have no `type` and are surfaced as
256
- // skips so consumers don't see them as events.
257
- return { kind: "skip", reason: "stream-control" };
258
- }
259
-
260
- const event = parsed as WebhookEventPayload;
261
-
262
- if (
263
- typeof event.id !== "string" ||
264
- typeof event.occurredAt !== "number" ||
265
- typeof event.receivedAt !== "number"
266
- ) {
267
- return {
268
- kind: "error",
269
- message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`,
270
- };
271
- }
272
- // purchaseToken is required for every event type *except*
273
- // TestNotification — Apple ASN v2 / Google RTDN test payloads
274
- // carry no transaction. Hard-rejecting here would surface valid
275
- // test webhooks as MALFORMED_EVENT and never reach listeners.
276
- if (
277
- event.type !== "TestNotification" &&
278
- typeof event.purchaseToken !== "string"
279
- ) {
280
- return {
281
- kind: "error",
282
- message: `WebhookEvent missing required field purchaseToken`,
283
- };
284
- }
285
-
286
- return { kind: "ok", event };
287
- }
288
-
289
- function trimTrailingSlash(url: string): string {
290
- return url.endsWith("/") ? url.slice(0, -1) : url;
291
- }
292
-
293
- function defaultEventSourceFactory(
294
- url: string,
295
- _headers: Record<string, string>,
296
- ): WebhookEventStream {
297
- // EventSource is part of the WHATWG spec and available in all
298
- // browser environments and most JS runtimes (Bun, Node 22+, Deno).
299
- // RN does not ship it natively — consumers must pass
300
- // `eventSourceFactory` from `react-native-sse` or similar.
301
- const ctor = (
302
- globalThis as {
303
- EventSource?: new (url: string) => WebhookEventStream;
304
- }
305
- ).EventSource;
306
- if (!ctor) {
307
- throw new Error(
308
- "EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.",
309
- );
310
- }
311
- return new ctor(url);
312
- }