@inno-isw/react-native-rtp 0.0.0-stage → 0.1.0

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 (55) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +362 -2
  3. package/lib/module/client.js +218 -0
  4. package/lib/module/client.js.map +1 -0
  5. package/lib/module/constants.js +44 -0
  6. package/lib/module/constants.js.map +1 -0
  7. package/lib/module/emitter.js +46 -0
  8. package/lib/module/emitter.js.map +1 -0
  9. package/lib/module/index.js +16 -0
  10. package/lib/module/index.js.map +1 -0
  11. package/lib/module/mock/makeRtpPayload.js +51 -0
  12. package/lib/module/mock/makeRtpPayload.js.map +1 -0
  13. package/lib/module/mock/mockTransport.js +82 -0
  14. package/lib/module/mock/mockTransport.js.map +1 -0
  15. package/lib/module/package.json +1 -0
  16. package/lib/module/push.js +96 -0
  17. package/lib/module/push.js.map +1 -0
  18. package/lib/module/react/RtpProvider.js +142 -0
  19. package/lib/module/react/RtpProvider.js.map +1 -0
  20. package/lib/module/types.js +4 -0
  21. package/lib/module/types.js.map +1 -0
  22. package/lib/module/validate.js +80 -0
  23. package/lib/module/validate.js.map +1 -0
  24. package/lib/typescript/package.json +1 -0
  25. package/lib/typescript/src/client.d.ts +11 -0
  26. package/lib/typescript/src/client.d.ts.map +1 -0
  27. package/lib/typescript/src/constants.d.ts +45 -0
  28. package/lib/typescript/src/constants.d.ts.map +1 -0
  29. package/lib/typescript/src/emitter.d.ts +15 -0
  30. package/lib/typescript/src/emitter.d.ts.map +1 -0
  31. package/lib/typescript/src/index.d.ts +14 -0
  32. package/lib/typescript/src/index.d.ts.map +1 -0
  33. package/lib/typescript/src/mock/makeRtpPayload.d.ts +17 -0
  34. package/lib/typescript/src/mock/makeRtpPayload.d.ts.map +1 -0
  35. package/lib/typescript/src/mock/mockTransport.d.ts +29 -0
  36. package/lib/typescript/src/mock/mockTransport.d.ts.map +1 -0
  37. package/lib/typescript/src/push.d.ts +41 -0
  38. package/lib/typescript/src/push.d.ts.map +1 -0
  39. package/lib/typescript/src/react/RtpProvider.d.ts +56 -0
  40. package/lib/typescript/src/react/RtpProvider.d.ts.map +1 -0
  41. package/lib/typescript/src/types.d.ts +142 -0
  42. package/lib/typescript/src/types.d.ts.map +1 -0
  43. package/lib/typescript/src/validate.d.ts +22 -0
  44. package/lib/typescript/src/validate.d.ts.map +1 -0
  45. package/package.json +141 -4
  46. package/src/client.ts +239 -0
  47. package/src/constants.ts +49 -0
  48. package/src/emitter.ts +53 -0
  49. package/src/index.tsx +43 -0
  50. package/src/mock/makeRtpPayload.ts +66 -0
  51. package/src/mock/mockTransport.ts +103 -0
  52. package/src/push.ts +109 -0
  53. package/src/react/RtpProvider.tsx +200 -0
  54. package/src/types.ts +161 -0
  55. package/src/validate.ts +93 -0
@@ -0,0 +1,200 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import type { RtpClient, RtpCredential, RtpRequest, RtpResult } from '../types';
11
+ import { RTP_STATUS } from '../constants';
12
+ import type { RtpStatus } from '../constants';
13
+
14
+ const STORAGE_KEY = 'rtp.pendingPayload';
15
+
16
+ /**
17
+ * A minimal persistence port for the provider. The host supplies its own
18
+ * storage (e.g. react-native-default-preference / AsyncStorage) so the SDK
19
+ * stays dependency-free. When omitted, persistence is in-memory only.
20
+ */
21
+ export interface RtpProviderStorage {
22
+ getItem: (key: string) => Promise<string | null> | string | null;
23
+ setItem: (key: string, value: string) => Promise<void> | void;
24
+ removeItem: (key: string) => Promise<void> | void;
25
+ }
26
+
27
+ export interface RtpProviderProps {
28
+ /** The client created via `createRtpClient`. */
29
+ client: RtpClient;
30
+ /** Whether the user is currently authenticated. Drives the auth gate. */
31
+ isAuthenticated: boolean;
32
+ /** Optional persistence used to survive cold starts / logged-out flows. */
33
+ storage?: RtpProviderStorage;
34
+ /**
35
+ * Called with the raw decoded payload before it is fed to the client.
36
+ * Use this to persist the payload yourself, log it, etc.
37
+ */
38
+ onIncomingPayload?: (payload: unknown) => void;
39
+ children: React.ReactNode;
40
+ }
41
+
42
+ export interface RtpContextValue {
43
+ /** The active request, if any. */
44
+ request: RtpRequest | null;
45
+ /** The active request's normalized status. */
46
+ status: RtpStatus | null;
47
+ /** True when a request is ready for the user to act on. */
48
+ isReady: boolean;
49
+ /** Accept the active request with an opaque credential. */
50
+ accept: (credential?: RtpCredential) => Promise<RtpResult | undefined>;
51
+ /** Decline the active request. */
52
+ decline: (reason?: string) => Promise<RtpResult | undefined>;
53
+ /** Dismiss the active request from the UI. */
54
+ dismiss: () => void;
55
+ /** Feed a raw decoded payload into the client (for host push adapters). */
56
+ receive: (payload: unknown) => void;
57
+ /** Last terminal result, for rendering feedback. */
58
+ lastResult: RtpResult | null;
59
+ }
60
+
61
+ export const RtpContext = createContext<RtpContextValue | null>(null);
62
+
63
+ /**
64
+ * Bridges the framework-agnostic RTP client into React. Owns no UI — hosts
65
+ * render their own bottom sheet / modal and react to `request` + `status`.
66
+ */
67
+ export function RtpProvider({
68
+ client,
69
+ isAuthenticated,
70
+ storage,
71
+ onIncomingPayload,
72
+ children,
73
+ }: RtpProviderProps) {
74
+ const [request, setRequest] = useState<RtpRequest | null>(null);
75
+ const [status, setStatus] = useState<RtpStatus | null>(null);
76
+ const [lastResult, setLastResult] = useState<RtpResult | null>(null);
77
+
78
+ // Keep the latest request id so async resume/drain can target it.
79
+ const activeIdRef = useRef<string | null>(null);
80
+
81
+ const receive = useCallback(
82
+ (payload: unknown) => {
83
+ onIncomingPayload?.(payload);
84
+ client.receive(payload);
85
+ },
86
+ [client, onIncomingPayload]
87
+ );
88
+
89
+ // Subscribe to client events exactly once per client.
90
+ useEffect(() => {
91
+ const offRequest = client.on('request', (req) => {
92
+ activeIdRef.current = req.id;
93
+ setRequest(req);
94
+ });
95
+
96
+ const offState = client.on('state', ({ id, status: next }) => {
97
+ if (activeIdRef.current && id !== activeIdRef.current) {
98
+ return;
99
+ }
100
+ setStatus(next);
101
+ });
102
+
103
+ const offResult = client.on('result', (result) => {
104
+ setLastResult(result);
105
+ });
106
+
107
+ return () => {
108
+ offRequest();
109
+ offState();
110
+ offResult();
111
+ };
112
+ }, [client]);
113
+
114
+ // Drain any payload persisted before React mounted (cold start / killed app).
115
+ useEffect(() => {
116
+ let cancelled = false;
117
+ async function drain() {
118
+ if (!storage) {
119
+ return;
120
+ }
121
+ const raw = await storage.getItem(STORAGE_KEY);
122
+ if (!raw || cancelled) {
123
+ return;
124
+ }
125
+ await storage.removeItem(STORAGE_KEY);
126
+ client.receive(JSON.parse(raw));
127
+ }
128
+ drain();
129
+ return () => {
130
+ cancelled = true;
131
+ };
132
+ }, [client, storage]);
133
+
134
+ // Auth gate: once authenticated, release any AWAITING_AUTH requests.
135
+ useEffect(() => {
136
+ if (isAuthenticated) {
137
+ client.resumeAuth();
138
+ }
139
+ }, [client, isAuthenticated]);
140
+
141
+ const accept = useCallback(
142
+ async (credential?: RtpCredential) => {
143
+ const id = request?.id ?? activeIdRef.current;
144
+ if (!id) {
145
+ return undefined;
146
+ }
147
+ return client.accept({ id, credential });
148
+ },
149
+ [client, request]
150
+ );
151
+
152
+ const decline = useCallback(
153
+ async (reason?: string) => {
154
+ const id = request?.id ?? activeIdRef.current;
155
+ if (!id) {
156
+ return undefined;
157
+ }
158
+ return client.decline({ id, reason });
159
+ },
160
+ [client, request]
161
+ );
162
+
163
+ const dismiss = useCallback(() => {
164
+ setRequest(null);
165
+ setStatus(null);
166
+ setLastResult(null);
167
+ activeIdRef.current = null;
168
+ }, []);
169
+
170
+ const isReady = status === RTP_STATUS.READY;
171
+
172
+ const value = useMemo<RtpContextValue>(
173
+ () => ({
174
+ request,
175
+ status,
176
+ isReady,
177
+ accept,
178
+ decline,
179
+ dismiss,
180
+ receive,
181
+ lastResult,
182
+ }),
183
+ [request, status, isReady, accept, decline, dismiss, receive, lastResult]
184
+ );
185
+
186
+ return <RtpContext.Provider value={value}>{children}</RtpContext.Provider>;
187
+ }
188
+
189
+ /**
190
+ * Access the RTP context. Throws when used outside of an `<RtpProvider>`.
191
+ */
192
+ export function useRtp(): RtpContextValue {
193
+ const context = useContext(RtpContext);
194
+ if (!context) {
195
+ throw new Error(
196
+ '[react-native-rtp] useRtp must be used within RtpProvider'
197
+ );
198
+ }
199
+ return context;
200
+ }
package/src/types.ts ADDED
@@ -0,0 +1,161 @@
1
+ import type { RtpError, RtpStatus } from './constants';
2
+
3
+ /**
4
+ * The decoded RTP payload.
5
+ *
6
+ * IMPORTANT: this is the *decoded* payload, not a push notification envelope.
7
+ * Each host platform is responsible for mapping its own push shape (RTCP,
8
+ * raw FCM, APNs, etc.) into this structure via an adapter. This keeps the SDK
9
+ * portable across React Native, Kotlin and Swift.
10
+ */
11
+ export interface RtpPayload {
12
+ /** Payload schema version. Unknown fields must be ignored, not rejected. */
13
+ version?: string;
14
+ /** Discriminator. Only `'RTP'` payloads are handled by this SDK. */
15
+ type: 'RTP';
16
+ /** Stable, unique id for the request (idempotency key). */
17
+ rtpId: string;
18
+ /** Amount in the currency's minor unit (e.g. kobo). Integer to avoid floats. */
19
+ amount: number;
20
+ /** ISO 4217 currency code. e.g. "NGN". */
21
+ currency: string;
22
+ /** Party being asked to pay. */
23
+ payer: RtpParty;
24
+ /** Party that will receive the money. */
25
+ payee: RtpParty;
26
+ /** Human-readable reason for the request. */
27
+ narration?: string;
28
+ /** Backend reference for the transaction. */
29
+ transactionRef?: string;
30
+ /** ISO 8601 timestamp after which the request can no longer be actioned. */
31
+ expiresAt: string;
32
+ /** Alias (phone/email) the request is addressed to, for identity matching. */
33
+ recipient?: string;
34
+ /** Arbitrary passthrough data. Ignored by the SDK. */
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /** A party on either side of a request (payer or payee). */
39
+ export interface RtpParty {
40
+ name: string;
41
+ accountNumber?: string;
42
+ bank?: string;
43
+ /** Optional display URL for a logo/avatar. */
44
+ logoUrl?: string;
45
+ }
46
+
47
+ /** A normalized, validated request held by the SDK. */
48
+ export interface RtpRequest {
49
+ id: string;
50
+ amount: number;
51
+ currency: string;
52
+ payer: RtpParty;
53
+ payee: RtpParty;
54
+ narration?: string;
55
+ transactionRef?: string;
56
+ expiresAt: string;
57
+ recipient?: string;
58
+ }
59
+
60
+ /**
61
+ * Credential produced by the host platform to authorise an accept.
62
+ * The SDK never inspects this — it is opaque and forwarded to the backend.
63
+ *
64
+ * For React Native this is typically `{ secureData, pinBlock, authData }`
65
+ * produced by the app's card/PIN service.
66
+ */
67
+ export type RtpCredential = Record<string, unknown>;
68
+
69
+ export interface RtpResult {
70
+ id: string;
71
+ status: RtpStatus;
72
+ error?: RtpError | string;
73
+ data?: unknown;
74
+ }
75
+
76
+ /** Outcome of feeding a raw payload into the client. */
77
+ export interface RtpReceiveOutcome {
78
+ /** False when the payload was not an RTP payload. */
79
+ handled: boolean;
80
+ request?: RtpRequest;
81
+ status?: RtpStatus;
82
+ error?: RtpError | string;
83
+ }
84
+
85
+ // --- Injected ports (the portability seams) -------------------------------
86
+
87
+ export interface RtpHttpResponse<T = any> {
88
+ data?: T;
89
+ status?: number;
90
+ }
91
+
92
+ /**
93
+ * Minimal HTTP contract. Hosts inject an implementation backed by their own
94
+ * client (`ky`, `axios`, native, ...). The SDK never imports a network library.
95
+ */
96
+ export interface RtpTransport {
97
+ post: <T = any>(
98
+ path: string,
99
+ body?: unknown,
100
+ headers?: Record<string, string>
101
+ ) => Promise<RtpHttpResponse<T>>;
102
+ get: <T = any>(
103
+ path: string,
104
+ headers?: Record<string, string>
105
+ ) => Promise<RtpHttpResponse<T>>;
106
+ }
107
+
108
+ /** Key/value storage for persistence across cold starts and auth gates. */
109
+ export interface RtpStorage {
110
+ getItem: (key: string) => Promise<string | null> | string | null;
111
+ setItem: (key: string, value: string) => Promise<void> | void;
112
+ removeItem: (key: string) => Promise<void> | void;
113
+ }
114
+
115
+ export interface RtpClientOptions {
116
+ /** Injected HTTP client. */
117
+ transport: RtpTransport;
118
+ /**
119
+ * Returns auth headers for an outgoing request. Called per request so the
120
+ * host can refresh tokens / compute MACs.
121
+ */
122
+ getAuthHeaders?: () =>
123
+ Promise<Record<string, string>> | Record<string, string>;
124
+ /**
125
+ * Returns the currently authenticated user's alias (phone/email), or null
126
+ * when logged out. Used for the auth gate and recipient matching.
127
+ */
128
+ getCurrentUser?: () => string | null | undefined;
129
+ /** Injectable clock (ms since epoch) for deterministic expiry tests. */
130
+ now?: () => number;
131
+ /** Optional sink for diagnostics. */
132
+ logger?: (...args: unknown[]) => void;
133
+ /** Endpoint base path for RTP routes. Defaults to `/rtp`. */
134
+ basePath?: string;
135
+ }
136
+
137
+ export type RtpUnsubscribe = () => void;
138
+
139
+ export interface RtpEventMap {
140
+ request: (request: RtpRequest) => void;
141
+ state: (event: { id: string; status: RtpStatus }) => void;
142
+ result: (result: RtpResult) => void;
143
+ }
144
+
145
+ export interface RtpClient {
146
+ isRtp: (payload: unknown) => payload is RtpPayload;
147
+ receive: (payload: unknown) => Promise<RtpReceiveOutcome>;
148
+ resumeAuth: () => void;
149
+ accept: (params: {
150
+ id: string;
151
+ credential?: RtpCredential;
152
+ }) => Promise<RtpResult>;
153
+ decline: (params: { id: string; reason?: string }) => Promise<RtpResult>;
154
+ getStatus: (id: string) => RtpStatus | undefined;
155
+ getRequest: (id: string) => RtpRequest | undefined;
156
+ acknowledge: (id: string) => void;
157
+ on: <E extends keyof RtpEventMap>(
158
+ event: E,
159
+ handler: RtpEventMap[E]
160
+ ) => RtpUnsubscribe;
161
+ }
@@ -0,0 +1,93 @@
1
+ import type { RtpParty, RtpPayload, RtpRequest } from './types';
2
+
3
+ /** Required top-level fields for a valid RTP payload. */
4
+ const REQUIRED_FIELDS = [
5
+ 'rtpId',
6
+ 'amount',
7
+ 'currency',
8
+ 'payer',
9
+ 'payee',
10
+ 'expiresAt',
11
+ ] as const;
12
+
13
+ /**
14
+ * Type guard for RTP payloads.
15
+ *
16
+ * NOTE: this intentionally inspects the *decoded* payload, not a push
17
+ * envelope. Hosts are responsible for extracting the payload from their own
18
+ * notification system first.
19
+ *
20
+ * `amount` is accepted as either a number (minor units) or a numeric string,
21
+ * because push payloads frequently stringify scalars in transit.
22
+ */
23
+ export function isRtpPayload(payload: unknown): payload is RtpPayload {
24
+ if (!payload || typeof payload !== 'object') {
25
+ return false;
26
+ }
27
+ const candidate = payload as Record<string, unknown>;
28
+ return candidate.type === 'RTP' && typeof candidate.rtpId === 'string';
29
+ }
30
+
31
+ /**
32
+ * Returns the list of missing/invalid required fields. Empty array means the
33
+ * payload is structurally valid.
34
+ */
35
+ export function findValidationErrors(payload: RtpPayload): string[] {
36
+ const errors: string[] = [];
37
+ const record = payload as unknown as Record<string, unknown>;
38
+
39
+ for (const field of REQUIRED_FIELDS) {
40
+ if (record[field] === undefined || record[field] === null) {
41
+ errors.push(field);
42
+ }
43
+ }
44
+
45
+ if (
46
+ payload.expiresAt !== undefined &&
47
+ Number.isNaN(new Date(payload.expiresAt).getTime())
48
+ ) {
49
+ errors.push('expiresAt');
50
+ }
51
+
52
+ for (const side of ['payer', 'payee'] as const) {
53
+ const party = payload[side] as RtpParty | undefined;
54
+ if (!party || typeof party !== 'object' || typeof party.name !== 'string') {
55
+ errors.push(`${side}.name`);
56
+ }
57
+ }
58
+
59
+ return errors;
60
+ }
61
+
62
+ /** Coerce an amount that may arrive as a number or numeric string. */
63
+ function toAmount(value: unknown): number {
64
+ if (typeof value === 'number') {
65
+ return value;
66
+ }
67
+ const parsed = Number(value);
68
+ return Number.isNaN(parsed) ? 0 : parsed;
69
+ }
70
+
71
+ /** Map a validated payload into the normalized request held by the SDK. */
72
+ export function toRtpRequest(payload: RtpPayload): RtpRequest {
73
+ return {
74
+ id: payload.rtpId,
75
+ amount: toAmount(payload.amount),
76
+ currency: payload.currency,
77
+ payer: payload.payer,
78
+ payee: payload.payee,
79
+ narration: payload.narration,
80
+ transactionRef: payload.transactionRef,
81
+ expiresAt: payload.expiresAt,
82
+ recipient: payload.recipient,
83
+ };
84
+ }
85
+
86
+ /** Pure expiry check. Re-evaluated on every transition, not just on receive. */
87
+ export function isExpired(request: RtpRequest, now: number): boolean {
88
+ const expiry = new Date(request.expiresAt).getTime();
89
+ if (Number.isNaN(expiry)) {
90
+ return false;
91
+ }
92
+ return expiry <= now;
93
+ }