expo-iap 4.2.2 → 4.2.4

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 (40) hide show
  1. package/build/index.d.ts +126 -33
  2. package/build/index.d.ts.map +1 -1
  3. package/build/index.js +123 -33
  4. package/build/index.js.map +1 -1
  5. package/build/kit-api.d.ts +54 -0
  6. package/build/kit-api.d.ts.map +1 -0
  7. package/build/kit-api.js +156 -0
  8. package/build/kit-api.js.map +1 -0
  9. package/build/modules/android.d.ts +22 -0
  10. package/build/modules/android.d.ts.map +1 -1
  11. package/build/modules/android.js +37 -0
  12. package/build/modules/android.js.map +1 -1
  13. package/build/modules/ios.d.ts +69 -1
  14. package/build/modules/ios.d.ts.map +1 -1
  15. package/build/modules/ios.js +73 -1
  16. package/build/modules/ios.js.map +1 -1
  17. package/build/types.d.ts +241 -75
  18. package/build/types.d.ts.map +1 -1
  19. package/build/types.js.map +1 -1
  20. package/build/useIAP.d.ts.map +1 -1
  21. package/build/useIAP.js +125 -3
  22. package/build/useIAP.js.map +1 -1
  23. package/build/useWebhookEvents.d.ts +26 -0
  24. package/build/useWebhookEvents.d.ts.map +1 -0
  25. package/build/useWebhookEvents.js +105 -0
  26. package/build/useWebhookEvents.js.map +1 -0
  27. package/build/webhook-client.d.ts +82 -0
  28. package/build/webhook-client.d.ts.map +1 -0
  29. package/build/webhook-client.js +176 -0
  30. package/build/webhook-client.js.map +1 -0
  31. package/openiap-versions.json +2 -2
  32. package/package.json +1 -1
  33. package/src/index.ts +141 -33
  34. package/src/kit-api.ts +229 -0
  35. package/src/modules/android.ts +47 -0
  36. package/src/modules/ios.ts +74 -1
  37. package/src/types.ts +247 -75
  38. package/src/useIAP.ts +125 -3
  39. package/src/useWebhookEvents.ts +155 -0
  40. package/src/webhook-client.ts +314 -0
@@ -0,0 +1,155 @@
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
+ apiKey: string | null | undefined;
13
+ baseUrl?: string;
14
+ eventSourceFactory?: (
15
+ url: string,
16
+ headers: Record<string, string>,
17
+ ) => WebhookEventStream;
18
+ bufferSize?: number;
19
+ onEvent?: (event: WebhookEventPayload) => void;
20
+ onError?: (error: WebhookListenerError) => void;
21
+ };
22
+
23
+ export type UseWebhookEventsResult = {
24
+ /** Most recent N events (most-recent-first). Capped at bufferSize. */
25
+ events: WebhookEventPayload[];
26
+ /** Last error reported by the underlying stream. Null when healthy. */
27
+ lastError: WebhookListenerError | null;
28
+ /**
29
+ * True once the first webhook event has been received from the
30
+ * stream. Remains false if the connection is open but idle (the
31
+ * underlying SSE bridge doesn't surface a "stream opened"
32
+ * lifecycle event we can hook into; isConnected is therefore an
33
+ * activity indicator, not a raw socket-state flag). Reset to
34
+ * false on cleanup / apiKey change.
35
+ */
36
+ isConnected: boolean;
37
+ };
38
+
39
+ // React hook wrapping the kit SSE webhook stream. See
40
+ // `libraries/react-native-iap/src/hooks/useWebhookEvents.ts` for the
41
+ // canonical version — this file mirrors it 1:1 because expo-iap and
42
+ // react-native-iap share the JS/TS SSE wire format. The intentional
43
+ // duplication keeps each library self-contained (no cross-package
44
+ // runtime dep) at the cost of a coordinated edit when the surface
45
+ // changes; that's checked by the SDK Parity Checklist in
46
+ // `knowledge/internal/04-platform-packages.md`.
47
+ export function useWebhookEvents({
48
+ apiKey,
49
+ baseUrl,
50
+ eventSourceFactory,
51
+ bufferSize = 50,
52
+ onEvent,
53
+ onError,
54
+ }: UseWebhookEventsOptions): UseWebhookEventsResult {
55
+ const [events, setEvents] = useState<WebhookEventPayload[]>([]);
56
+ const [lastError, setLastError] = useState<WebhookListenerError | null>(null);
57
+ const [isConnected, setIsConnected] = useState(false);
58
+
59
+ const onEventRef = useRef(onEvent);
60
+ const onErrorRef = useRef(onError);
61
+ // Hold `eventSourceFactory` in a ref too so a caller passing an
62
+ // anonymous function literal (the common React pitfall) doesn't
63
+ // tear down the SSE connection on every render. We still capture
64
+ // the latest factory so a runtime-config swap (e.g. apiKey changes
65
+ // and a new EventSource constructor is needed) is honored on the
66
+ // next connect, but the *identity* of the factory no longer drives
67
+ // useEffect.
68
+ const eventSourceFactoryRef = useRef(eventSourceFactory);
69
+ // Holding bufferSize in a ref so adjusting it from the host
70
+ // component doesn't tear down the SSE connection. Same reasoning
71
+ // as onEvent / onError: a re-render with a new bufferSize would
72
+ // otherwise re-fire useEffect, close the stream, and reconnect
73
+ // (losing in-flight events the SSE handler had already buffered).
74
+ const bufferSizeRef = useRef(bufferSize);
75
+ onEventRef.current = onEvent;
76
+ onErrorRef.current = onError;
77
+ eventSourceFactoryRef.current = eventSourceFactory;
78
+ bufferSizeRef.current = bufferSize;
79
+
80
+ // Trim the existing buffer when the host lowers `bufferSize`
81
+ // mid-stream. The ref-based update only takes effect on the next
82
+ // event arrival, which can leave the visible buffer above the new
83
+ // cap until traffic resumes — this effect enforces the cap
84
+ // immediately on the change instead.
85
+ useEffect(() => {
86
+ setEvents((prev) => (bufferSize > 0 ? prev.slice(0, bufferSize) : []));
87
+ }, [bufferSize]);
88
+
89
+ useEffect(() => {
90
+ // Reset surfaced state on every (re)connect target so a stale
91
+ // event from the prior stream can't briefly leak into a new
92
+ // apiKey/baseUrl context. Matches the SSE convention of
93
+ // "fresh stream → fresh history."
94
+ setEvents([]);
95
+ setLastError(null);
96
+
97
+ if (!apiKey) {
98
+ return;
99
+ }
100
+
101
+ let listener: WebhookListener | null = null;
102
+ let mounted = true;
103
+
104
+ try {
105
+ listener = connectWebhookStream({
106
+ apiKey,
107
+ baseUrl,
108
+ eventSourceFactory: eventSourceFactoryRef.current,
109
+ onEvent: (event) => {
110
+ if (!mounted) {
111
+ return;
112
+ }
113
+ setIsConnected(true);
114
+ const cap = bufferSizeRef.current;
115
+ if (cap > 0) {
116
+ setEvents((prev) => [event, ...prev].slice(0, cap));
117
+ }
118
+ onEventRef.current?.(event);
119
+ },
120
+ onError: (error) => {
121
+ if (!mounted) {
122
+ return;
123
+ }
124
+ setLastError(error);
125
+ onErrorRef.current?.(error);
126
+ },
127
+ });
128
+ } catch (error) {
129
+ const wrapped: WebhookListenerError = {
130
+ code: 'TRANSPORT_ERROR',
131
+ message:
132
+ error instanceof Error
133
+ ? error.message
134
+ : 'Failed to open webhook stream',
135
+ cause: error,
136
+ };
137
+ setLastError(wrapped);
138
+ onErrorRef.current?.(wrapped);
139
+ }
140
+
141
+ return () => {
142
+ mounted = false;
143
+ listener?.close();
144
+ setIsConnected(false);
145
+ };
146
+ // `eventSourceFactory` deliberately omitted from deps — held in a
147
+ // ref above so anonymous-function callers don't trigger reconnects
148
+ // on every render. The connection is only re-opened when apiKey or
149
+ // baseUrl changes; a runtime factory swap is picked up on that
150
+ // next reconnect via the ref.
151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
152
+ }, [apiKey, baseUrl]);
153
+
154
+ return {events, lastError, isConnected};
155
+ }
@@ -0,0 +1,314 @@
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(
135
+ baseUrl,
136
+ )}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`;
137
+
138
+ const factory = options.eventSourceFactory ?? defaultEventSourceFactory;
139
+ let stream: WebhookEventStream;
140
+ try {
141
+ stream = factory(url, {});
142
+ } catch (error) {
143
+ options.onError?.({
144
+ code: 'NO_EVENTSOURCE',
145
+ message:
146
+ error instanceof Error
147
+ ? error.message
148
+ : 'EventSource constructor unavailable in this runtime',
149
+ cause: error,
150
+ });
151
+ return {close: () => {}};
152
+ }
153
+
154
+ const seenIds = new Set<string>();
155
+ const seenOrder: string[] = [];
156
+ const markSeen = (id: string): boolean => {
157
+ if (seenIds.has(id)) {
158
+ return true;
159
+ }
160
+ seenIds.add(id);
161
+ seenOrder.push(id);
162
+ if (seenOrder.length > 1024) {
163
+ const evicted = seenOrder.shift();
164
+ if (evicted !== undefined) {
165
+ seenIds.delete(evicted);
166
+ }
167
+ }
168
+ return false;
169
+ };
170
+
171
+ const handleData = (raw: string) => {
172
+ const parsed = parseWebhookEventData(raw);
173
+ if (parsed.kind === 'error') {
174
+ options.onError?.({
175
+ code: 'PARSE_ERROR',
176
+ message: parsed.message,
177
+ });
178
+ return;
179
+ }
180
+ if (parsed.kind === 'skip') {
181
+ return;
182
+ }
183
+ if (markSeen(parsed.event.id)) {
184
+ return;
185
+ }
186
+ options.onEvent(parsed.event);
187
+ };
188
+
189
+ if (typeof stream.addEventListener === 'function') {
190
+ stream.addEventListener('message', (event) => handleData(event.data));
191
+ // WHATWG EventSource dispatches frames with `event: Foo` only to
192
+ // listeners registered for `Foo`, not to `message` / `onmessage`.
193
+ // Kit emits webhook frames as typed SSE events, so subscribe to
194
+ // every known webhook type and keep `message` for older servers or
195
+ // polyfills that collapse typed frames into the generic channel.
196
+ for (const eventType of WEBHOOK_EVENT_TYPES) {
197
+ stream.addEventListener(eventType, (event) => handleData(event.data));
198
+ }
199
+ } else {
200
+ stream.onmessage = (event) => handleData(event.data);
201
+ }
202
+
203
+ stream.onerror = (error) => {
204
+ options.onError?.({
205
+ code: 'TRANSPORT_ERROR',
206
+ message: 'SSE transport error (auto-reconnecting)',
207
+ cause: error,
208
+ });
209
+ };
210
+
211
+ return {
212
+ close: () => {
213
+ try {
214
+ stream.close();
215
+ } catch {
216
+ // Closing an already-closed EventSource is a no-op in browsers
217
+ // but throws in some polyfills.
218
+ }
219
+ },
220
+ };
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Pure helpers (exported for testing).
225
+ // ---------------------------------------------------------------------------
226
+
227
+ export type ParsedEventResult =
228
+ | {kind: 'ok'; event: WebhookEventPayload}
229
+ | {kind: 'skip'; reason: 'heartbeat' | 'stream-control'}
230
+ | {kind: 'error'; message: string};
231
+
232
+ export function parseWebhookEventData(raw: string): ParsedEventResult {
233
+ if (!raw) {
234
+ return {kind: 'skip', reason: 'heartbeat'};
235
+ }
236
+
237
+ let parsed: unknown;
238
+ try {
239
+ parsed = JSON.parse(raw);
240
+ } catch (error) {
241
+ return {
242
+ kind: 'error',
243
+ message:
244
+ error instanceof Error
245
+ ? `Failed to parse SSE payload: ${error.message}`
246
+ : 'Failed to parse SSE payload',
247
+ };
248
+ }
249
+
250
+ if (
251
+ typeof parsed !== 'object' ||
252
+ parsed === null ||
253
+ !('type' in parsed) ||
254
+ typeof (parsed as Record<string, unknown>).type !== 'string'
255
+ ) {
256
+ // Stream-control messages (the `ready`/`stream-error` envelopes
257
+ // emitted by the kit server) have no `type` and are surfaced as
258
+ // skips so consumers don't see them as events.
259
+ return {kind: 'skip', reason: 'stream-control'};
260
+ }
261
+
262
+ const event = parsed as WebhookEventPayload;
263
+
264
+ if (
265
+ typeof event.id !== 'string' ||
266
+ typeof event.occurredAt !== 'number' ||
267
+ typeof event.receivedAt !== 'number'
268
+ ) {
269
+ return {
270
+ kind: 'error',
271
+ message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`,
272
+ };
273
+ }
274
+ // purchaseToken is required for every event type *except*
275
+ // TestNotification — Apple ASN v2 / Google RTDN test payloads
276
+ // carry no transaction. Hard-rejecting here would surface valid
277
+ // test webhooks as MALFORMED_EVENT and never reach listeners.
278
+ if (
279
+ event.type !== 'TestNotification' &&
280
+ typeof event.purchaseToken !== 'string'
281
+ ) {
282
+ return {
283
+ kind: 'error',
284
+ message: `WebhookEvent missing required field purchaseToken`,
285
+ };
286
+ }
287
+
288
+ return {kind: 'ok', event};
289
+ }
290
+
291
+ function trimTrailingSlash(url: string): string {
292
+ return url.endsWith('/') ? url.slice(0, -1) : url;
293
+ }
294
+
295
+ function defaultEventSourceFactory(
296
+ url: string,
297
+ _headers: Record<string, string>,
298
+ ): WebhookEventStream {
299
+ // EventSource is part of the WHATWG spec and available in all
300
+ // browser environments and most JS runtimes (Bun, Node 22+, Deno).
301
+ // RN does not ship it natively — consumers must pass
302
+ // `eventSourceFactory` from `react-native-sse` or similar.
303
+ const ctor = (
304
+ globalThis as {
305
+ EventSource?: new (url: string) => WebhookEventStream;
306
+ }
307
+ ).EventSource;
308
+ if (!ctor) {
309
+ throw new Error(
310
+ 'EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.',
311
+ );
312
+ }
313
+ return new ctor(url);
314
+ }