pulse-updates 1.1.1 → 1.2.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.
- package/README.md +50 -10
- package/SECURITY.md +42 -0
- package/android/src/main/java/app/pulse/updates/PulseController.kt +6 -2
- package/android/src/main/java/app/pulse/updates/PulseUpdatesModule.kt +25 -0
- package/app.plugin.js +47 -0
- package/ios/PulseUpdates/PulseController.swift +7 -2
- package/ios/PulseUpdates/PulseUpdates.m +7 -0
- package/ios/PulseUpdates/PulseUpdates.swift +22 -0
- package/lib/commonjs/NativePulseUpdates.js.map +1 -1
- package/lib/commonjs/PulseUpdates.js +36 -0
- package/lib/commonjs/PulseUpdates.js.map +1 -1
- package/lib/commonjs/config.js +175 -21
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/decisions.js +128 -0
- package/lib/commonjs/decisions.js.map +1 -0
- package/lib/commonjs/index.js +12 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/init.js +153 -13
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/track.js +154 -12
- package/lib/commonjs/track.js.map +1 -1
- package/lib/commonjs/usePulseUpdates.js +21 -16
- package/lib/commonjs/usePulseUpdates.js.map +1 -1
- package/lib/module/NativePulseUpdates.js.map +1 -1
- package/lib/module/PulseUpdates.js +33 -0
- package/lib/module/PulseUpdates.js.map +1 -1
- package/lib/module/config.js +174 -21
- package/lib/module/config.js.map +1 -1
- package/lib/module/decisions.js +122 -0
- package/lib/module/decisions.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/init.js +154 -15
- package/lib/module/init.js.map +1 -1
- package/lib/module/track.js +152 -12
- package/lib/module/track.js.map +1 -1
- package/lib/module/usePulseUpdates.js +21 -16
- package/lib/module/usePulseUpdates.js.map +1 -1
- package/lib/typescript/NativePulseUpdates.d.ts +1 -0
- package/lib/typescript/NativePulseUpdates.d.ts.map +1 -1
- package/lib/typescript/PulseUpdates.d.ts +20 -0
- package/lib/typescript/PulseUpdates.d.ts.map +1 -1
- package/lib/typescript/config.d.ts +22 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/decisions.d.ts +55 -0
- package/lib/typescript/decisions.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +51 -4
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +29 -1
- package/lib/typescript/track.d.ts.map +1 -1
- package/lib/typescript/usePulseUpdates.d.ts.map +1 -1
- package/logo.png +0 -0
- package/package.json +14 -3
- package/scripts/publish.mjs +68 -3
- package/src/NativePulseUpdates.ts +1 -0
- package/src/PulseUpdates.ts +54 -0
- package/src/config.ts +234 -21
- package/src/decisions.ts +179 -0
- package/src/index.ts +1 -0
- package/src/init.ts +227 -12
- package/src/track.ts +191 -13
- package/src/usePulseUpdates.ts +21 -16
package/src/decisions.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export type DecisionChannel = 'in_app' | 'encore' | 'replio' | 'email' | 'push' | 'sms' | 'whatsapp' | 'webhook';
|
|
2
|
+
export type DecisionOutcome = 'pending' | 'delivered' | 'dismissed' | 'accepted' | 'converted' | 'failed';
|
|
3
|
+
|
|
4
|
+
export interface DecisionContext {
|
|
5
|
+
userId?: string;
|
|
6
|
+
deviceId?: string;
|
|
7
|
+
platform?: string;
|
|
8
|
+
appVersion?: string;
|
|
9
|
+
country?: string;
|
|
10
|
+
language?: string;
|
|
11
|
+
osVersion?: string;
|
|
12
|
+
attributes?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DecisionAction { kind: string; [key: string]: unknown }
|
|
16
|
+
|
|
17
|
+
export interface PulseDecision {
|
|
18
|
+
decisionId: string;
|
|
19
|
+
replayed: boolean;
|
|
20
|
+
execute: boolean;
|
|
21
|
+
mode: 'shadow' | 'live' | string;
|
|
22
|
+
verdict: string;
|
|
23
|
+
reasonCode: string;
|
|
24
|
+
policyKey?: string | null;
|
|
25
|
+
policyVersion?: number | null;
|
|
26
|
+
action?: DecisionAction | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DecisionHandlerResult {
|
|
30
|
+
outcome?: DecisionOutcome;
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DecisionHandler = (
|
|
35
|
+
action: DecisionAction,
|
|
36
|
+
decision: PulseDecision,
|
|
37
|
+
) => void | DecisionHandlerResult | Promise<void | DecisionHandlerResult>;
|
|
38
|
+
|
|
39
|
+
export interface PulseDecisionClientOptions {
|
|
40
|
+
apiUrl: string;
|
|
41
|
+
appSlug: string;
|
|
42
|
+
getContext: () => DecisionContext | Promise<DecisionContext>;
|
|
43
|
+
fetch?: typeof globalThis.fetch;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
/** Safe default. User-facing execution needs an explicit per-app opt-in. */
|
|
46
|
+
executionMode?: 'shadow-only' | 'allow-live';
|
|
47
|
+
handlers?: Record<string, DecisionHandler>;
|
|
48
|
+
onError?: (error: unknown) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DecideOptions {
|
|
52
|
+
channel?: DecisionChannel;
|
|
53
|
+
idempotencyKey?: string;
|
|
54
|
+
attributes?: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PulseDecisionClient {
|
|
58
|
+
decide(trigger: string, options?: DecideOptions): Promise<PulseDecision | null>;
|
|
59
|
+
reportOutcome(decisionId: string, outcome: DecisionOutcome, metadata?: Record<string, unknown>): Promise<boolean>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Generic decision protocol. Product-specific action handlers remain in each app. */
|
|
63
|
+
export function createPulseDecisionClient(options: PulseDecisionClientOptions): PulseDecisionClient {
|
|
64
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
65
|
+
const base = options.apiUrl.replace(/\/+$/, '');
|
|
66
|
+
const slug = encodeURIComponent(options.appSlug);
|
|
67
|
+
const timeoutMs = Math.max(250, options.timeoutMs ?? 3_000);
|
|
68
|
+
const executionMode = options.executionMode ?? 'shadow-only';
|
|
69
|
+
|
|
70
|
+
const reportOutcome = async (
|
|
71
|
+
decisionId: string,
|
|
72
|
+
outcome: DecisionOutcome,
|
|
73
|
+
metadata?: Record<string, unknown>,
|
|
74
|
+
): Promise<boolean> => {
|
|
75
|
+
try {
|
|
76
|
+
const response = await timedFetch(fetcher,
|
|
77
|
+
`${base}/pulse/decisions/${slug}/${encodeURIComponent(decisionId)}/outcome`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ outcome, ...(metadata ? { metadata } : {}) }),
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
return response.ok;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
options.onError?.(error);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const decide = async (trigger: string, request: DecideOptions = {}): Promise<PulseDecision | null> => {
|
|
90
|
+
try {
|
|
91
|
+
const context = await options.getContext();
|
|
92
|
+
const response = await timedFetch(fetcher, `${base}/pulse/decisions/${slug}`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
...context,
|
|
97
|
+
attributes: { ...(context.attributes ?? {}), ...(request.attributes ?? {}) },
|
|
98
|
+
trigger,
|
|
99
|
+
channel: request.channel ?? 'in_app',
|
|
100
|
+
idempotencyKey: request.idempotencyKey ?? randomKey(),
|
|
101
|
+
}),
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
if (!response.ok) throw new Error(`Pulse decision HTTP ${response.status}`);
|
|
104
|
+
|
|
105
|
+
const decision = parseDecision(await response.json());
|
|
106
|
+
if (!decision.execute) return decision;
|
|
107
|
+
if (executionMode !== 'allow-live') {
|
|
108
|
+
void reportOutcome(decision.decisionId, 'failed', { reason: 'client_executor_not_armed' });
|
|
109
|
+
return decision;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const action = decision.action;
|
|
113
|
+
const handler = action?.kind ? options.handlers?.[action.kind] : undefined;
|
|
114
|
+
if (!action || !handler) {
|
|
115
|
+
void reportOutcome(decision.decisionId, 'failed', {
|
|
116
|
+
reason: 'unknown_action_kind',
|
|
117
|
+
kind: action?.kind ?? null,
|
|
118
|
+
});
|
|
119
|
+
return decision;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await handler(action, decision);
|
|
124
|
+
void reportOutcome(decision.decisionId, result?.outcome ?? 'accepted', result?.metadata);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
options.onError?.(error);
|
|
127
|
+
void reportOutcome(decision.decisionId, 'failed', { reason: 'handler_failed' });
|
|
128
|
+
}
|
|
129
|
+
return decision;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
options.onError?.(error);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return { decide, reportOutcome };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseDecision(raw: unknown): PulseDecision {
|
|
140
|
+
if (!raw || typeof raw !== 'object') throw new Error('Malformed Pulse decision');
|
|
141
|
+
const value = raw as Record<string, unknown>;
|
|
142
|
+
if (typeof value.decisionId !== 'string' || typeof value.execute !== 'boolean' ||
|
|
143
|
+
typeof value.mode !== 'string' || typeof value.verdict !== 'string' ||
|
|
144
|
+
typeof value.reasonCode !== 'string') throw new Error('Malformed Pulse decision');
|
|
145
|
+
const action = value.action;
|
|
146
|
+
if (action != null && (typeof action !== 'object' ||
|
|
147
|
+
typeof (action as Record<string, unknown>).kind !== 'string')) {
|
|
148
|
+
throw new Error('Malformed Pulse action');
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
decisionId: value.decisionId,
|
|
152
|
+
replayed: value.replayed === true,
|
|
153
|
+
execute: value.execute,
|
|
154
|
+
mode: value.mode,
|
|
155
|
+
verdict: value.verdict,
|
|
156
|
+
reasonCode: value.reasonCode,
|
|
157
|
+
policyKey: typeof value.policyKey === 'string' ? value.policyKey : null,
|
|
158
|
+
policyVersion: typeof value.policyVersion === 'number' ? value.policyVersion : null,
|
|
159
|
+
action: (action as DecisionAction | null | undefined) ?? null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function timedFetch(
|
|
164
|
+
fetcher: typeof globalThis.fetch,
|
|
165
|
+
url: string,
|
|
166
|
+
init: RequestInit,
|
|
167
|
+
timeoutMs: number,
|
|
168
|
+
): Promise<Response> {
|
|
169
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
170
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
171
|
+
try { return await fetcher(url, { ...init, signal: controller?.signal }); }
|
|
172
|
+
finally { if (timer) clearTimeout(timer); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function randomKey(): string {
|
|
176
|
+
const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
|
177
|
+
if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
|
|
178
|
+
return `moment-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
|
|
179
|
+
}
|
package/src/index.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -30,8 +30,21 @@ import {
|
|
|
30
30
|
type ConfigContext,
|
|
31
31
|
type ConfigStorage,
|
|
32
32
|
type ConfigValue,
|
|
33
|
+
type ConfigSignature,
|
|
34
|
+
getConfigInfo,
|
|
35
|
+
getConfigBoolean,
|
|
36
|
+
getConfigNumber,
|
|
37
|
+
getConfigString,
|
|
38
|
+
getConfigJson,
|
|
33
39
|
} from './config';
|
|
34
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
configureTracking,
|
|
42
|
+
disposeTracking,
|
|
43
|
+
flushEvents,
|
|
44
|
+
getTrackingInfo,
|
|
45
|
+
track,
|
|
46
|
+
type TrackedEventInput,
|
|
47
|
+
} from './track';
|
|
35
48
|
|
|
36
49
|
export interface InitPulseOptions {
|
|
37
50
|
/** Where Pulse lives, e.g. https://pulse.example.com — no path. */
|
|
@@ -53,6 +66,9 @@ export interface InitPulseOptions {
|
|
|
53
66
|
/** The store build, e.g. "5.2.0". Needed to target a rollout at a version. */
|
|
54
67
|
appVersion?: string;
|
|
55
68
|
|
|
69
|
+
/** Locale override. When omitted the installed RN locale adapter is detected. */
|
|
70
|
+
language?: string;
|
|
71
|
+
|
|
56
72
|
/**
|
|
57
73
|
* A stable id for this install. Left out, one is minted and persisted.
|
|
58
74
|
*
|
|
@@ -68,6 +84,19 @@ export interface InitPulseOptions {
|
|
|
68
84
|
/** Attributes a rule can target — a plan, a storefront. Keep them few and stable. */
|
|
69
85
|
getUserAttributes?: () => Record<string, string> | undefined;
|
|
70
86
|
|
|
87
|
+
/** Initial analytics consent or a live consent reader. Defaults to granted for compatibility. */
|
|
88
|
+
analyticsConsent?: boolean | (() => boolean);
|
|
89
|
+
|
|
90
|
+
/** Privacy guardrails applied to every track() call. */
|
|
91
|
+
eventPropertyAllowlist?: readonly string[];
|
|
92
|
+
redactEventProperties?: readonly string[];
|
|
93
|
+
|
|
94
|
+
/** Signed config uses the same Ed25519 public key as OTA updates. */
|
|
95
|
+
signingPublicKey?: string;
|
|
96
|
+
signingKeyId?: string;
|
|
97
|
+
requireConfigSignature?: boolean;
|
|
98
|
+
verifyConfigSignature?: (canonicalPayload: string, signature: ConfigSignature) => boolean | Promise<boolean>;
|
|
99
|
+
|
|
71
100
|
/** Foreground poll interval for the config. 0 disables it; launch and resume still fetch. */
|
|
72
101
|
pollIntervalMs?: number;
|
|
73
102
|
|
|
@@ -85,39 +114,108 @@ export interface InitPulseOptions {
|
|
|
85
114
|
|
|
86
115
|
const DEVICE_ID_KEY = 'pulse.device-id';
|
|
87
116
|
|
|
117
|
+
export interface PulseHealth {
|
|
118
|
+
appSlug: string;
|
|
119
|
+
deviceId: string;
|
|
120
|
+
config: ReturnType<typeof getConfigInfo>;
|
|
121
|
+
events: ReturnType<typeof getTrackingInfo>;
|
|
122
|
+
analyticsConsent: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface PulseClient {
|
|
126
|
+
deviceId: string;
|
|
127
|
+
configUrl: string;
|
|
128
|
+
trackUrl: string;
|
|
129
|
+
track: (event: string, props?: TrackedEventInput['props'], time?: Date) => void;
|
|
130
|
+
flush: () => Promise<number>;
|
|
131
|
+
setUser: (userId?: string, attributes?: Record<string, string>) => void;
|
|
132
|
+
clearUser: () => void;
|
|
133
|
+
setAnalyticsConsent: (granted: boolean) => void;
|
|
134
|
+
resetInstallId: () => string;
|
|
135
|
+
health: () => PulseHealth;
|
|
136
|
+
config: {
|
|
137
|
+
boolean: typeof getConfigBoolean;
|
|
138
|
+
number: typeof getConfigNumber;
|
|
139
|
+
string: typeof getConfigString;
|
|
140
|
+
json: typeof getConfigJson;
|
|
141
|
+
refresh: typeof fetchConfig;
|
|
142
|
+
};
|
|
143
|
+
dispose: () => Promise<void>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface AsyncPulseStorage {
|
|
147
|
+
getItem(key: string): Promise<string | null>;
|
|
148
|
+
setItem(key: string, value: string): Promise<void>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let activeClient: PulseClient | null = null;
|
|
152
|
+
let stopActiveRefresh: (() => void) | null = null;
|
|
153
|
+
|
|
88
154
|
/** Wires config and events, and returns the context the two share. */
|
|
89
|
-
export function initPulse(opts: InitPulseOptions):
|
|
155
|
+
export function initPulse(opts: InitPulseOptions): PulseClient {
|
|
156
|
+
// Reconfiguration is synchronous. Calling the old async dispose without awaiting
|
|
157
|
+
// it lets its eventual flush tear down the brand-new tracker — exactly the race a
|
|
158
|
+
// fast app switch or test setup triggers. Persist the old outbox, stop its refresh,
|
|
159
|
+
// then configure the new app in one uninterrupted turn.
|
|
160
|
+
stopActiveRefresh?.();
|
|
161
|
+
disposeTracking();
|
|
162
|
+
activeClient = null;
|
|
163
|
+
stopActiveRefresh = null;
|
|
90
164
|
const base = opts.apiUrl.replace(/\/+$/, '');
|
|
91
165
|
const slug = opts.appSlug.trim();
|
|
92
166
|
const configUrl = `${base}/pulse/config/${slug}`;
|
|
93
167
|
const trackUrl = `${base}/pulse/track/${slug}`;
|
|
94
168
|
|
|
95
|
-
const
|
|
169
|
+
const deviceStorageKey = `pulse.${slug}.device-id`;
|
|
170
|
+
let deviceId = opts.deviceId?.trim() || resolveDeviceId(opts.storage, deviceStorageKey);
|
|
96
171
|
const platform = detectPlatform();
|
|
97
172
|
const osVersion = detectOsVersion();
|
|
173
|
+
const appVersion = opts.appVersion ?? detectAppVersion();
|
|
174
|
+
const language = opts.language ?? detectLanguage();
|
|
175
|
+
let userId: string | undefined;
|
|
176
|
+
let userAttributes: Record<string, string> | undefined;
|
|
177
|
+
let consentOverride: boolean | undefined = typeof opts.analyticsConsent === 'boolean'
|
|
178
|
+
? opts.analyticsConsent
|
|
179
|
+
: undefined;
|
|
180
|
+
|
|
181
|
+
const hasConsent = (): boolean => consentOverride ??
|
|
182
|
+
(typeof opts.analyticsConsent === 'function' ? opts.analyticsConsent() : true);
|
|
98
183
|
|
|
99
184
|
const getContext = (): ConfigContext => ({
|
|
100
185
|
platform,
|
|
101
186
|
osVersion,
|
|
102
|
-
appVersion
|
|
187
|
+
appVersion,
|
|
188
|
+
language,
|
|
103
189
|
deviceId,
|
|
104
|
-
userId: opts.getUserId?.(),
|
|
105
|
-
userAttributes: opts.getUserAttributes?.(),
|
|
190
|
+
userId: userId ?? opts.getUserId?.(),
|
|
191
|
+
userAttributes: userAttributes ?? opts.getUserAttributes?.(),
|
|
106
192
|
});
|
|
107
193
|
|
|
108
194
|
configureConfig({
|
|
109
195
|
url: configUrl,
|
|
110
196
|
defaults: opts.defaults,
|
|
111
197
|
storage: opts.storage,
|
|
198
|
+
storageKey: `pulse.${slug}.config.v1`,
|
|
112
199
|
getContext,
|
|
113
200
|
pollIntervalMs: opts.pollIntervalMs,
|
|
114
201
|
onError: opts.onError,
|
|
202
|
+
signingPublicKey: opts.signingPublicKey,
|
|
203
|
+
signingKeyId: opts.signingKeyId,
|
|
204
|
+
requireSignature: opts.requireConfigSignature,
|
|
205
|
+
verifySignature: opts.verifyConfigSignature ??
|
|
206
|
+
(opts.requireConfigSignature && !opts.signingPublicKey ? verifyWithNativeKey : undefined),
|
|
115
207
|
});
|
|
116
208
|
|
|
117
209
|
configureTracking({
|
|
118
210
|
url: trackUrl,
|
|
119
211
|
getContext,
|
|
120
212
|
flushIntervalMs: opts.flushIntervalMs,
|
|
213
|
+
storage: opts.storage,
|
|
214
|
+
storageKey: `pulse.${slug}.events.v1`,
|
|
215
|
+
hasConsent,
|
|
216
|
+
propertyAllowlist: opts.eventPropertyAllowlist,
|
|
217
|
+
redactProperties: opts.redactEventProperties,
|
|
218
|
+
appState: opts.appState,
|
|
121
219
|
onIgnored: opts.onIgnoredEvents,
|
|
122
220
|
onError: opts.onError,
|
|
123
221
|
});
|
|
@@ -127,7 +225,7 @@ export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl
|
|
|
127
225
|
// simply does not match this install — which reads as "the rollout did nothing".
|
|
128
226
|
// Without storage the id is new on every launch, so the arm is re-dealt each time
|
|
129
227
|
// and no experiment can mean anything.
|
|
130
|
-
if (!
|
|
228
|
+
if (!appVersion) {
|
|
131
229
|
opts.onError?.(new Error(
|
|
132
230
|
'Pulse: no appVersion given — version targeting and version slices will not match this install'));
|
|
133
231
|
}
|
|
@@ -140,9 +238,77 @@ export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl
|
|
|
140
238
|
// the first response is the launch that runs on defaults, and defaults are the
|
|
141
239
|
// off position of every flag.
|
|
142
240
|
void fetchConfig();
|
|
143
|
-
startConfigAutoRefresh(opts.appState);
|
|
241
|
+
const stopRefresh = startConfigAutoRefresh(opts.appState);
|
|
242
|
+
|
|
243
|
+
const client: PulseClient = {
|
|
244
|
+
get deviceId() { return deviceId; },
|
|
245
|
+
configUrl,
|
|
246
|
+
trackUrl,
|
|
247
|
+
track,
|
|
248
|
+
flush: flushEvents,
|
|
249
|
+
setUser: (nextUserId, attributes) => {
|
|
250
|
+
userId = nextUserId?.trim() || undefined;
|
|
251
|
+
userAttributes = sanitizeAttributes(attributes);
|
|
252
|
+
},
|
|
253
|
+
clearUser: () => {
|
|
254
|
+
userId = undefined;
|
|
255
|
+
userAttributes = undefined;
|
|
256
|
+
},
|
|
257
|
+
setAnalyticsConsent: (granted) => { consentOverride = granted; },
|
|
258
|
+
resetInstallId: () => {
|
|
259
|
+
deviceId = randomId();
|
|
260
|
+
try { opts.storage?.set(deviceStorageKey, deviceId); } catch { /* memory-only fallback */ }
|
|
261
|
+
return deviceId;
|
|
262
|
+
},
|
|
263
|
+
health: () => ({ appSlug: slug, deviceId, config: getConfigInfo(), events: getTrackingInfo(), analyticsConsent: hasConsent() }),
|
|
264
|
+
config: {
|
|
265
|
+
boolean: getConfigBoolean,
|
|
266
|
+
number: getConfigNumber,
|
|
267
|
+
string: getConfigString,
|
|
268
|
+
json: getConfigJson,
|
|
269
|
+
refresh: fetchConfig,
|
|
270
|
+
},
|
|
271
|
+
dispose: async () => {
|
|
272
|
+
stopRefresh();
|
|
273
|
+
if (activeClient !== client) return;
|
|
274
|
+
await flushEvents();
|
|
275
|
+
// Another init may have taken ownership while this network flush was pending.
|
|
276
|
+
if (activeClient === client) {
|
|
277
|
+
disposeTracking();
|
|
278
|
+
activeClient = null;
|
|
279
|
+
stopActiveRefresh = null;
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
activeClient = client;
|
|
284
|
+
stopActiveRefresh = stopRefresh;
|
|
285
|
+
return client;
|
|
286
|
+
}
|
|
144
287
|
|
|
145
|
-
|
|
288
|
+
/**
|
|
289
|
+
* AsyncStorage-friendly one-call setup. It preloads only Pulse's three app-scoped
|
|
290
|
+
* records, then exposes the same synchronous hot-path API as initPulse.
|
|
291
|
+
*/
|
|
292
|
+
export async function initPulseAsync(
|
|
293
|
+
opts: Omit<InitPulseOptions, 'storage'> & { storage: AsyncPulseStorage },
|
|
294
|
+
): Promise<PulseClient> {
|
|
295
|
+
const slug = opts.appSlug.trim();
|
|
296
|
+
const keys = [
|
|
297
|
+
`pulse.${slug}.device-id`,
|
|
298
|
+
`pulse.${slug}.config.v1`,
|
|
299
|
+
`pulse.${slug}.events.v1`,
|
|
300
|
+
DEVICE_ID_KEY,
|
|
301
|
+
];
|
|
302
|
+
const loaded = await Promise.all(keys.map(async (key) => [key, await opts.storage.getItem(key)] as const));
|
|
303
|
+
const memory = new Map(loaded.filter((entry): entry is readonly [string, string] => entry[1] !== null));
|
|
304
|
+
const storage: ConfigStorage = {
|
|
305
|
+
getString: (key) => memory.get(key),
|
|
306
|
+
set: (key, value) => {
|
|
307
|
+
memory.set(key, value);
|
|
308
|
+
void opts.storage.setItem(key, value);
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
return initPulse({ ...opts, storage });
|
|
146
312
|
}
|
|
147
313
|
|
|
148
314
|
/**
|
|
@@ -152,20 +318,28 @@ export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl
|
|
|
152
318
|
* arm is a hash of it, so every launch would be a fresh coin toss and no experiment
|
|
153
319
|
* could mean anything. Said out loud through onError rather than hidden.
|
|
154
320
|
*/
|
|
155
|
-
function resolveDeviceId(storage?: ConfigStorage): string {
|
|
321
|
+
function resolveDeviceId(storage?: ConfigStorage, key = DEVICE_ID_KEY): string {
|
|
156
322
|
if (!storage) return randomId();
|
|
157
323
|
|
|
158
324
|
try {
|
|
159
|
-
const existing = storage.getString(DEVICE_ID_KEY);
|
|
325
|
+
const existing = storage.getString(key) ?? storage.getString(DEVICE_ID_KEY);
|
|
160
326
|
if (existing) return existing;
|
|
161
327
|
const minted = randomId();
|
|
162
|
-
storage.set(
|
|
328
|
+
storage.set(key, minted);
|
|
163
329
|
return minted;
|
|
164
330
|
} catch {
|
|
165
331
|
return randomId();
|
|
166
332
|
}
|
|
167
333
|
}
|
|
168
334
|
|
|
335
|
+
function sanitizeAttributes(attributes?: Record<string, string>): Record<string, string> | undefined {
|
|
336
|
+
if (!attributes) return undefined;
|
|
337
|
+
const clean = Object.fromEntries(Object.entries(attributes)
|
|
338
|
+
.filter(([key, value]) => key.trim() !== '' && typeof value === 'string')
|
|
339
|
+
.slice(0, 50));
|
|
340
|
+
return Object.keys(clean).length > 0 ? clean : undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
169
343
|
function randomId(): string {
|
|
170
344
|
const bytes = new Uint8Array(16);
|
|
171
345
|
const crypto = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => void } }).crypto;
|
|
@@ -208,3 +382,44 @@ function detectOsVersion(): string | undefined {
|
|
|
208
382
|
return undefined;
|
|
209
383
|
}
|
|
210
384
|
}
|
|
385
|
+
|
|
386
|
+
function detectAppVersion(): string | undefined {
|
|
387
|
+
try {
|
|
388
|
+
// Optional by design: most RN apps already carry this package, plain JS hosts do not.
|
|
389
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
390
|
+
const loaded = require('react-native-device-info') as { default?: { getVersion?: () => string }; getVersion?: () => string };
|
|
391
|
+
return loaded.default?.getVersion?.() ?? loaded.getVersion?.();
|
|
392
|
+
} catch {
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function verifyWithNativeKey(
|
|
398
|
+
canonicalPayload: string,
|
|
399
|
+
signature: ConfigSignature,
|
|
400
|
+
): Promise<boolean> {
|
|
401
|
+
try {
|
|
402
|
+
// Kept lazy so init/config remain usable in Node, web and test hosts without RN.
|
|
403
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
404
|
+
const updates = require('./PulseUpdates') as {
|
|
405
|
+
verifyConfigSignatureAsync?: (payload: string, value: ConfigSignature) => Promise<boolean>;
|
|
406
|
+
};
|
|
407
|
+
return await updates.verifyConfigSignatureAsync?.(canonicalPayload, signature) ?? false;
|
|
408
|
+
} catch {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function detectLanguage(): string | undefined {
|
|
414
|
+
try {
|
|
415
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
416
|
+
const localize = require('react-native-localize') as { getLocales?: () => Array<{ languageTag?: string }> };
|
|
417
|
+
return localize.getLocales?.()[0]?.languageTag;
|
|
418
|
+
} catch {
|
|
419
|
+
try {
|
|
420
|
+
return Intl.DateTimeFormat().resolvedOptions().locale;
|
|
421
|
+
} catch {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|