pulse-updates 1.0.13 → 1.0.15

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.
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.configureConfig = configureConfig;
7
+ exports.fetchConfig = fetchConfig;
8
+ exports.getAllConfig = getAllConfig;
9
+ exports.getConfigBoolean = getConfigBoolean;
10
+ exports.getConfigInfo = getConfigInfo;
11
+ exports.getConfigNumber = getConfigNumber;
12
+ exports.getConfigString = getConfigString;
13
+ exports.getConfigValue = getConfigValue;
14
+ exports.onConfigChange = onConfigChange;
15
+ exports.resetConfigForTests = resetConfigForTests;
16
+ exports.setConfigDefaults = setConfigDefaults;
17
+ exports.startConfigAutoRefresh = startConfigAutoRefresh;
18
+ /**
19
+ * Pulse Config — the client half of the Firebase Remote Config replacement.
20
+ *
21
+ * Why this exists: from 2026-09-01 Firebase bills every Remote Config fetch above
22
+ * 100k/day and throttles projects that stay on the free plan. Worse than the bill is
23
+ * the failure mode — the Firebase SDK yields the type's zero value for a key it does
24
+ * not hold, so a throttled fetch reads as "every flag is false" and, for an ads
25
+ * config, quietly turns the revenue off.
26
+ *
27
+ * The shape here deliberately mirrors what the apps already do with Remote Config —
28
+ * defaults, fetch, activate, typed getters — so swapping the source is a change of
29
+ * adapter, not of every call site. Three things are different on purpose:
30
+ *
31
+ * 1. A missing key falls back to the registered default, never to false/0. That is
32
+ * the exact bug this replaces.
33
+ * 2. The last good values are persisted and reloaded at boot, so a failed fetch
34
+ * degrades to yesterday's config rather than to nothing.
35
+ * 3. Fetching is cheap (our server, no per-request price), so it happens on launch,
36
+ * on every return from foreground, and on a timer — with ETag, so an unchanged
37
+ * config costs a 304 with no body.
38
+ */
39
+
40
+ /**
41
+ * Everything the server may ever want to target on. Sent on every request even when
42
+ * no rule uses it yet: the client can only start sending a new field with a store
43
+ * build, so a contract that is incomplete today cannot be completed for months.
44
+ */
45
+
46
+ const STORAGE_KEY = 'pulse.config.v1';
47
+ const DEFAULT_POLL_MS = 5 * 60 * 1000;
48
+ const DEFAULT_TIMEOUT_MS = 10_000;
49
+ let options = null;
50
+ let defaults = {};
51
+ let values = {};
52
+ let etag = null;
53
+ let fetchedAt = 0;
54
+ let source = 'defaults';
55
+ let inFlight = null;
56
+ let pollTimer = null;
57
+ const listeners = new Set();
58
+
59
+ /**
60
+ * Registers defaults and reloads the last good payload from storage, synchronously,
61
+ * so the very first read at boot already has real values instead of a blank config
62
+ * while the network call is in flight.
63
+ */
64
+ function configureConfig(opts) {
65
+ options = opts;
66
+ defaults = {
67
+ ...(opts.defaults ?? {})
68
+ };
69
+ const cached = readCache(opts.storage);
70
+ if (cached) {
71
+ values = cached.values;
72
+ etag = cached.etag;
73
+ fetchedAt = cached.fetchedAt;
74
+ source = 'cache';
75
+ }
76
+ }
77
+
78
+ /** Merge more defaults after configure (a late-loading module registering its own). */
79
+ function setConfigDefaults(more) {
80
+ defaults = {
81
+ ...defaults,
82
+ ...more
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Fetch, and adopt the payload if it changed. Returns true when new values were
88
+ * applied — false for "nothing changed" (304) and false for a failure, which are
89
+ * deliberately indistinguishable to callers: in both cases the current values stand.
90
+ * Errors surface through onError instead of rejecting, because no caller should have
91
+ * to wrap a config refresh in a try/catch to keep the app running.
92
+ */
93
+ async function fetchConfig() {
94
+ if (!options) return false;
95
+ // Collapse concurrent calls (launch + resume can land together) onto one request.
96
+ if (inFlight) return inFlight;
97
+ inFlight = (async () => {
98
+ const opts = options;
99
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
100
+ const timer = controller ? setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) : null;
101
+ try {
102
+ const response = await fetch(opts.url, {
103
+ method: 'GET',
104
+ headers: buildHeaders(opts, etag),
105
+ signal: controller?.signal
106
+ });
107
+ if (response.status === 304) return false;
108
+ if (!response.ok) throw new Error(`Pulse config HTTP ${response.status}`);
109
+ const payload = await response.json();
110
+ const nextValues = payload?.values ?? {};
111
+ values = nextValues;
112
+ etag = response.headers?.get?.('etag') ?? null;
113
+ fetchedAt = Date.now();
114
+ source = 'remote';
115
+ writeCache(opts.storage, {
116
+ values,
117
+ etag,
118
+ fetchedAt
119
+ });
120
+ notify();
121
+ return true;
122
+ } catch (error) {
123
+ // A failed refresh must never clear what we have: the whole point of the cache
124
+ // is that an unreachable server degrades to the last good config.
125
+ opts.onError?.(error);
126
+ return false;
127
+ } finally {
128
+ if (timer) clearTimeout(timer);
129
+ inFlight = null;
130
+ }
131
+ })();
132
+ return inFlight;
133
+ }
134
+
135
+ /**
136
+ * Fetch on launch, on every return to the foreground, and on a timer.
137
+ * Returns a function that stops both.
138
+ */
139
+ function startConfigAutoRefresh(appState) {
140
+ void fetchConfig();
141
+ const subscription = appState?.addEventListener('change', state => {
142
+ if (state === 'active') void fetchConfig();
143
+ });
144
+ const interval = options?.pollIntervalMs ?? DEFAULT_POLL_MS;
145
+ if (interval > 0) {
146
+ stopPolling();
147
+ pollTimer = setInterval(() => void fetchConfig(), interval);
148
+ }
149
+ return () => {
150
+ subscription?.remove();
151
+ stopPolling();
152
+ };
153
+ }
154
+ function stopPolling() {
155
+ if (pollTimer) {
156
+ clearInterval(pollTimer);
157
+ pollTimer = null;
158
+ }
159
+ }
160
+
161
+ // ─── Reads ────────────────────────────────────────────────────────────────────
162
+
163
+ /** The served value, or the registered default. Never a type's zero value. */
164
+ function getConfigValue(key) {
165
+ if (Object.prototype.hasOwnProperty.call(values, key) && values[key] !== null) {
166
+ return values[key];
167
+ }
168
+ const fallback = defaults[key];
169
+ return fallback === undefined ? null : fallback;
170
+ }
171
+ function getConfigBoolean(key) {
172
+ const value = getConfigValue(key);
173
+ if (typeof value === 'boolean') return value;
174
+ // A server that sends "true" as a string must not read as false.
175
+ if (typeof value === 'string') return value.toLowerCase() === 'true';
176
+ if (typeof value === 'number') return value !== 0;
177
+ return false;
178
+ }
179
+ function getConfigNumber(key) {
180
+ const value = getConfigValue(key);
181
+ if (typeof value === 'number') return value;
182
+ if (typeof value === 'string') {
183
+ const parsed = Number(value);
184
+ if (!Number.isNaN(parsed)) return parsed;
185
+ }
186
+ return 0;
187
+ }
188
+ function getConfigString(key) {
189
+ const value = getConfigValue(key);
190
+ if (typeof value === 'string') return value;
191
+ if (value === null || value === undefined) return '';
192
+ return String(value);
193
+ }
194
+
195
+ /** Defaults first, then everything the server sent — what a caller would see key by key. */
196
+ function getAllConfig() {
197
+ return {
198
+ ...defaults,
199
+ ...values
200
+ };
201
+ }
202
+ function onConfigChange(listener) {
203
+ listeners.add(listener);
204
+ return () => listeners.delete(listener);
205
+ }
206
+
207
+ /** For diagnostics: where the current values came from and how old they are. */
208
+ function getConfigInfo() {
209
+ return {
210
+ source,
211
+ fetchedAt,
212
+ etag,
213
+ keyCount: Object.keys(getAllConfig()).length
214
+ };
215
+ }
216
+
217
+ /** Test seam: drops every piece of module state. */
218
+ function resetConfigForTests() {
219
+ options = null;
220
+ defaults = {};
221
+ values = {};
222
+ etag = null;
223
+ fetchedAt = 0;
224
+ source = 'defaults';
225
+ inFlight = null;
226
+ listeners.clear();
227
+ stopPolling();
228
+ }
229
+
230
+ // ─── Internals ────────────────────────────────────────────────────────────────
231
+
232
+ function buildHeaders(opts, currentEtag) {
233
+ const ctx = opts.getContext?.() ?? {};
234
+ const headers = {
235
+ Accept: 'application/json'
236
+ };
237
+ if (ctx.platform) headers['X-Pulse-Platform'] = ctx.platform;
238
+ if (ctx.appVersion) headers['X-Pulse-App-Version'] = ctx.appVersion;
239
+ if (ctx.osVersion) headers['X-Pulse-Os-Version'] = ctx.osVersion;
240
+ if (ctx.language) headers['X-Pulse-Language'] = ctx.language;
241
+ if (ctx.deviceId) headers['Pulse-Device-Id'] = ctx.deviceId;
242
+ if (ctx.userAttributes && Object.keys(ctx.userAttributes).length > 0) {
243
+ headers['X-Pulse-User-Attributes'] = JSON.stringify(ctx.userAttributes);
244
+ }
245
+ // Country is deliberately absent: the server reads it from the edge, which a client
246
+ // header cannot spoof to opt into a targeted rollout.
247
+
248
+ if (currentEtag) headers['If-None-Match'] = currentEtag;
249
+ return headers;
250
+ }
251
+ function readCache(storage) {
252
+ if (!storage) return null;
253
+ try {
254
+ const raw = storage.getString(STORAGE_KEY);
255
+ if (!raw) return null;
256
+ const parsed = JSON.parse(raw);
257
+ if (!parsed || typeof parsed !== 'object' || !parsed.values) return null;
258
+ return parsed;
259
+ } catch {
260
+ // A corrupt cache is not worth a crash at boot: fall back to defaults.
261
+ return null;
262
+ }
263
+ }
264
+ function writeCache(storage, payload) {
265
+ if (!storage) return;
266
+ try {
267
+ storage.set(STORAGE_KEY, JSON.stringify(payload));
268
+ } catch {
269
+ // Persistence is an optimisation; failing to write must not fail the fetch.
270
+ }
271
+ }
272
+ function notify() {
273
+ const snapshot = getAllConfig();
274
+ listeners.forEach(listener => {
275
+ try {
276
+ listener(snapshot);
277
+ } catch {
278
+ // One bad listener must not stop the others from being told.
279
+ }
280
+ });
281
+ }
282
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["STORAGE_KEY","DEFAULT_POLL_MS","DEFAULT_TIMEOUT_MS","options","defaults","values","etag","fetchedAt","source","inFlight","pollTimer","listeners","Set","configureConfig","opts","cached","readCache","storage","setConfigDefaults","more","fetchConfig","controller","AbortController","timer","setTimeout","abort","timeoutMs","response","fetch","url","method","headers","buildHeaders","signal","status","ok","Error","payload","json","nextValues","get","Date","now","writeCache","notify","error","onError","clearTimeout","startConfigAutoRefresh","appState","subscription","addEventListener","state","interval","pollIntervalMs","stopPolling","setInterval","remove","clearInterval","getConfigValue","key","Object","prototype","hasOwnProperty","call","fallback","undefined","getConfigBoolean","value","toLowerCase","getConfigNumber","parsed","Number","isNaN","getConfigString","String","getAllConfig","onConfigChange","listener","add","delete","getConfigInfo","keyCount","keys","length","resetConfigForTests","clear","currentEtag","ctx","getContext","Accept","platform","appVersion","osVersion","language","deviceId","userAttributes","JSON","stringify","raw","getString","parse","set","snapshot","forEach"],"sourceRoot":"../../src","sources":["config.ts"],"mappings":"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","ignoreList":[]}
@@ -52,4 +52,16 @@ Object.keys(_types).forEach(function (key) {
52
52
  });
53
53
  var _usePulseUpdates = require("./usePulseUpdates");
54
54
  var _assetResolver = require("./assetResolver");
55
+ var _config = require("./config");
56
+ Object.keys(_config).forEach(function (key) {
57
+ if (key === "default" || key === "__esModule") return;
58
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
59
+ if (key in exports && exports[key] === _config[key]) return;
60
+ Object.defineProperty(exports, key, {
61
+ enumerable: true,
62
+ get: function () {
63
+ return _config[key];
64
+ }
65
+ });
66
+ });
55
67
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["_PulseUpdates","require","Object","keys","forEach","key","prototype","hasOwnProperty","call","_exportNames","exports","defineProperty","enumerable","get","_types","_usePulseUpdates","_assetResolver"],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,IAAAA,aAAA,GAAAC,OAAA;AAAAC,MAAA,CAAAC,IAAA,CAAAH,aAAA,EAAAI,OAAA,WAAAC,GAAA;EAAA,IAAAA,GAAA,kBAAAA,GAAA;EAAA,IAAAH,MAAA,CAAAI,SAAA,CAAAC,cAAA,CAAAC,IAAA,CAAAC,YAAA,EAAAJ,GAAA;EAAA,IAAAA,GAAA,IAAAK,OAAA,IAAAA,OAAA,CAAAL,GAAA,MAAAL,aAAA,CAAAK,GAAA;EAAAH,MAAA,CAAAS,cAAA,CAAAD,OAAA,EAAAL,GAAA;IAAAO,UAAA;IAAAC,GAAA,WAAAA,CAAA;MAAA,OAAAb,aAAA,CAAAK,GAAA;IAAA;EAAA;AAAA;AACA,IAAAS,MAAA,GAAAb,OAAA;AAAAC,MAAA,CAAAC,IAAA,CAAAW,MAAA,EAAAV,OAAA,WAAAC,GAAA;EAAA,IAAAA,GAAA,kBAAAA,GAAA;EAAA,IAAAH,MAAA,CAAAI,SAAA,CAAAC,cAAA,CAAAC,IAAA,CAAAC,YAAA,EAAAJ,GAAA;EAAA,IAAAA,GAAA,IAAAK,OAAA,IAAAA,OAAA,CAAAL,GAAA,MAAAS,MAAA,CAAAT,GAAA;EAAAH,MAAA,CAAAS,cAAA,CAAAD,OAAA,EAAAL,GAAA;IAAAO,UAAA;IAAAC,GAAA,WAAAA,CAAA;MAAA,OAAAC,MAAA,CAAAT,GAAA;IAAA;EAAA;AAAA;AACA,IAAAU,gBAAA,GAAAd,OAAA;AACA,IAAAe,cAAA,GAAAf,OAAA","ignoreList":[]}
1
+ {"version":3,"names":["_PulseUpdates","require","Object","keys","forEach","key","prototype","hasOwnProperty","call","_exportNames","exports","defineProperty","enumerable","get","_types","_usePulseUpdates","_assetResolver","_config"],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,IAAAA,aAAA,GAAAC,OAAA;AAAAC,MAAA,CAAAC,IAAA,CAAAH,aAAA,EAAAI,OAAA,WAAAC,GAAA;EAAA,IAAAA,GAAA,kBAAAA,GAAA;EAAA,IAAAH,MAAA,CAAAI,SAAA,CAAAC,cAAA,CAAAC,IAAA,CAAAC,YAAA,EAAAJ,GAAA;EAAA,IAAAA,GAAA,IAAAK,OAAA,IAAAA,OAAA,CAAAL,GAAA,MAAAL,aAAA,CAAAK,GAAA;EAAAH,MAAA,CAAAS,cAAA,CAAAD,OAAA,EAAAL,GAAA;IAAAO,UAAA;IAAAC,GAAA,WAAAA,CAAA;MAAA,OAAAb,aAAA,CAAAK,GAAA;IAAA;EAAA;AAAA;AACA,IAAAS,MAAA,GAAAb,OAAA;AAAAC,MAAA,CAAAC,IAAA,CAAAW,MAAA,EAAAV,OAAA,WAAAC,GAAA;EAAA,IAAAA,GAAA,kBAAAA,GAAA;EAAA,IAAAH,MAAA,CAAAI,SAAA,CAAAC,cAAA,CAAAC,IAAA,CAAAC,YAAA,EAAAJ,GAAA;EAAA,IAAAA,GAAA,IAAAK,OAAA,IAAAA,OAAA,CAAAL,GAAA,MAAAS,MAAA,CAAAT,GAAA;EAAAH,MAAA,CAAAS,cAAA,CAAAD,OAAA,EAAAL,GAAA;IAAAO,UAAA;IAAAC,GAAA,WAAAA,CAAA;MAAA,OAAAC,MAAA,CAAAT,GAAA;IAAA;EAAA;AAAA;AACA,IAAAU,gBAAA,GAAAd,OAAA;AACA,IAAAe,cAAA,GAAAf,OAAA;AACA,IAAAgB,OAAA,GAAAhB,OAAA;AAAAC,MAAA,CAAAC,IAAA,CAAAc,OAAA,EAAAb,OAAA,WAAAC,GAAA;EAAA,IAAAA,GAAA,kBAAAA,GAAA;EAAA,IAAAH,MAAA,CAAAI,SAAA,CAAAC,cAAA,CAAAC,IAAA,CAAAC,YAAA,EAAAJ,GAAA;EAAA,IAAAA,GAAA,IAAAK,OAAA,IAAAA,OAAA,CAAAL,GAAA,MAAAY,OAAA,CAAAZ,GAAA;EAAAH,MAAA,CAAAS,cAAA,CAAAD,OAAA,EAAAL,GAAA;IAAAO,UAAA;IAAAC,GAAA,WAAAA,CAAA;MAAA,OAAAI,OAAA,CAAAZ,GAAA;IAAA;EAAA;AAAA","ignoreList":[]}
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Pulse Config — the client half of the Firebase Remote Config replacement.
3
+ *
4
+ * Why this exists: from 2026-09-01 Firebase bills every Remote Config fetch above
5
+ * 100k/day and throttles projects that stay on the free plan. Worse than the bill is
6
+ * the failure mode — the Firebase SDK yields the type's zero value for a key it does
7
+ * not hold, so a throttled fetch reads as "every flag is false" and, for an ads
8
+ * config, quietly turns the revenue off.
9
+ *
10
+ * The shape here deliberately mirrors what the apps already do with Remote Config —
11
+ * defaults, fetch, activate, typed getters — so swapping the source is a change of
12
+ * adapter, not of every call site. Three things are different on purpose:
13
+ *
14
+ * 1. A missing key falls back to the registered default, never to false/0. That is
15
+ * the exact bug this replaces.
16
+ * 2. The last good values are persisted and reloaded at boot, so a failed fetch
17
+ * degrades to yesterday's config rather than to nothing.
18
+ * 3. Fetching is cheap (our server, no per-request price), so it happens on launch,
19
+ * on every return from foreground, and on a timer — with ETag, so an unchanged
20
+ * config costs a 304 with no body.
21
+ */
22
+
23
+ /**
24
+ * Everything the server may ever want to target on. Sent on every request even when
25
+ * no rule uses it yet: the client can only start sending a new field with a store
26
+ * build, so a contract that is incomplete today cannot be completed for months.
27
+ */
28
+
29
+ const STORAGE_KEY = 'pulse.config.v1';
30
+ const DEFAULT_POLL_MS = 5 * 60 * 1000;
31
+ const DEFAULT_TIMEOUT_MS = 10_000;
32
+ let options = null;
33
+ let defaults = {};
34
+ let values = {};
35
+ let etag = null;
36
+ let fetchedAt = 0;
37
+ let source = 'defaults';
38
+ let inFlight = null;
39
+ let pollTimer = null;
40
+ const listeners = new Set();
41
+
42
+ /**
43
+ * Registers defaults and reloads the last good payload from storage, synchronously,
44
+ * so the very first read at boot already has real values instead of a blank config
45
+ * while the network call is in flight.
46
+ */
47
+ export function configureConfig(opts) {
48
+ options = opts;
49
+ defaults = {
50
+ ...(opts.defaults ?? {})
51
+ };
52
+ const cached = readCache(opts.storage);
53
+ if (cached) {
54
+ values = cached.values;
55
+ etag = cached.etag;
56
+ fetchedAt = cached.fetchedAt;
57
+ source = 'cache';
58
+ }
59
+ }
60
+
61
+ /** Merge more defaults after configure (a late-loading module registering its own). */
62
+ export function setConfigDefaults(more) {
63
+ defaults = {
64
+ ...defaults,
65
+ ...more
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Fetch, and adopt the payload if it changed. Returns true when new values were
71
+ * applied — false for "nothing changed" (304) and false for a failure, which are
72
+ * deliberately indistinguishable to callers: in both cases the current values stand.
73
+ * Errors surface through onError instead of rejecting, because no caller should have
74
+ * to wrap a config refresh in a try/catch to keep the app running.
75
+ */
76
+ export async function fetchConfig() {
77
+ if (!options) return false;
78
+ // Collapse concurrent calls (launch + resume can land together) onto one request.
79
+ if (inFlight) return inFlight;
80
+ inFlight = (async () => {
81
+ const opts = options;
82
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
83
+ const timer = controller ? setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) : null;
84
+ try {
85
+ const response = await fetch(opts.url, {
86
+ method: 'GET',
87
+ headers: buildHeaders(opts, etag),
88
+ signal: controller?.signal
89
+ });
90
+ if (response.status === 304) return false;
91
+ if (!response.ok) throw new Error(`Pulse config HTTP ${response.status}`);
92
+ const payload = await response.json();
93
+ const nextValues = payload?.values ?? {};
94
+ values = nextValues;
95
+ etag = response.headers?.get?.('etag') ?? null;
96
+ fetchedAt = Date.now();
97
+ source = 'remote';
98
+ writeCache(opts.storage, {
99
+ values,
100
+ etag,
101
+ fetchedAt
102
+ });
103
+ notify();
104
+ return true;
105
+ } catch (error) {
106
+ // A failed refresh must never clear what we have: the whole point of the cache
107
+ // is that an unreachable server degrades to the last good config.
108
+ opts.onError?.(error);
109
+ return false;
110
+ } finally {
111
+ if (timer) clearTimeout(timer);
112
+ inFlight = null;
113
+ }
114
+ })();
115
+ return inFlight;
116
+ }
117
+
118
+ /**
119
+ * Fetch on launch, on every return to the foreground, and on a timer.
120
+ * Returns a function that stops both.
121
+ */
122
+ export function startConfigAutoRefresh(appState) {
123
+ void fetchConfig();
124
+ const subscription = appState?.addEventListener('change', state => {
125
+ if (state === 'active') void fetchConfig();
126
+ });
127
+ const interval = options?.pollIntervalMs ?? DEFAULT_POLL_MS;
128
+ if (interval > 0) {
129
+ stopPolling();
130
+ pollTimer = setInterval(() => void fetchConfig(), interval);
131
+ }
132
+ return () => {
133
+ subscription?.remove();
134
+ stopPolling();
135
+ };
136
+ }
137
+ function stopPolling() {
138
+ if (pollTimer) {
139
+ clearInterval(pollTimer);
140
+ pollTimer = null;
141
+ }
142
+ }
143
+
144
+ // ─── Reads ────────────────────────────────────────────────────────────────────
145
+
146
+ /** The served value, or the registered default. Never a type's zero value. */
147
+ export function getConfigValue(key) {
148
+ if (Object.prototype.hasOwnProperty.call(values, key) && values[key] !== null) {
149
+ return values[key];
150
+ }
151
+ const fallback = defaults[key];
152
+ return fallback === undefined ? null : fallback;
153
+ }
154
+ export function getConfigBoolean(key) {
155
+ const value = getConfigValue(key);
156
+ if (typeof value === 'boolean') return value;
157
+ // A server that sends "true" as a string must not read as false.
158
+ if (typeof value === 'string') return value.toLowerCase() === 'true';
159
+ if (typeof value === 'number') return value !== 0;
160
+ return false;
161
+ }
162
+ export function getConfigNumber(key) {
163
+ const value = getConfigValue(key);
164
+ if (typeof value === 'number') return value;
165
+ if (typeof value === 'string') {
166
+ const parsed = Number(value);
167
+ if (!Number.isNaN(parsed)) return parsed;
168
+ }
169
+ return 0;
170
+ }
171
+ export function getConfigString(key) {
172
+ const value = getConfigValue(key);
173
+ if (typeof value === 'string') return value;
174
+ if (value === null || value === undefined) return '';
175
+ return String(value);
176
+ }
177
+
178
+ /** Defaults first, then everything the server sent — what a caller would see key by key. */
179
+ export function getAllConfig() {
180
+ return {
181
+ ...defaults,
182
+ ...values
183
+ };
184
+ }
185
+ export function onConfigChange(listener) {
186
+ listeners.add(listener);
187
+ return () => listeners.delete(listener);
188
+ }
189
+
190
+ /** For diagnostics: where the current values came from and how old they are. */
191
+ export function getConfigInfo() {
192
+ return {
193
+ source,
194
+ fetchedAt,
195
+ etag,
196
+ keyCount: Object.keys(getAllConfig()).length
197
+ };
198
+ }
199
+
200
+ /** Test seam: drops every piece of module state. */
201
+ export function resetConfigForTests() {
202
+ options = null;
203
+ defaults = {};
204
+ values = {};
205
+ etag = null;
206
+ fetchedAt = 0;
207
+ source = 'defaults';
208
+ inFlight = null;
209
+ listeners.clear();
210
+ stopPolling();
211
+ }
212
+
213
+ // ─── Internals ────────────────────────────────────────────────────────────────
214
+
215
+ function buildHeaders(opts, currentEtag) {
216
+ const ctx = opts.getContext?.() ?? {};
217
+ const headers = {
218
+ Accept: 'application/json'
219
+ };
220
+ if (ctx.platform) headers['X-Pulse-Platform'] = ctx.platform;
221
+ if (ctx.appVersion) headers['X-Pulse-App-Version'] = ctx.appVersion;
222
+ if (ctx.osVersion) headers['X-Pulse-Os-Version'] = ctx.osVersion;
223
+ if (ctx.language) headers['X-Pulse-Language'] = ctx.language;
224
+ if (ctx.deviceId) headers['Pulse-Device-Id'] = ctx.deviceId;
225
+ if (ctx.userAttributes && Object.keys(ctx.userAttributes).length > 0) {
226
+ headers['X-Pulse-User-Attributes'] = JSON.stringify(ctx.userAttributes);
227
+ }
228
+ // Country is deliberately absent: the server reads it from the edge, which a client
229
+ // header cannot spoof to opt into a targeted rollout.
230
+
231
+ if (currentEtag) headers['If-None-Match'] = currentEtag;
232
+ return headers;
233
+ }
234
+ function readCache(storage) {
235
+ if (!storage) return null;
236
+ try {
237
+ const raw = storage.getString(STORAGE_KEY);
238
+ if (!raw) return null;
239
+ const parsed = JSON.parse(raw);
240
+ if (!parsed || typeof parsed !== 'object' || !parsed.values) return null;
241
+ return parsed;
242
+ } catch {
243
+ // A corrupt cache is not worth a crash at boot: fall back to defaults.
244
+ return null;
245
+ }
246
+ }
247
+ function writeCache(storage, payload) {
248
+ if (!storage) return;
249
+ try {
250
+ storage.set(STORAGE_KEY, JSON.stringify(payload));
251
+ } catch {
252
+ // Persistence is an optimisation; failing to write must not fail the fetch.
253
+ }
254
+ }
255
+ function notify() {
256
+ const snapshot = getAllConfig();
257
+ listeners.forEach(listener => {
258
+ try {
259
+ listener(snapshot);
260
+ } catch {
261
+ // One bad listener must not stop the others from being told.
262
+ }
263
+ });
264
+ }
265
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["STORAGE_KEY","DEFAULT_POLL_MS","DEFAULT_TIMEOUT_MS","options","defaults","values","etag","fetchedAt","source","inFlight","pollTimer","listeners","Set","configureConfig","opts","cached","readCache","storage","setConfigDefaults","more","fetchConfig","controller","AbortController","timer","setTimeout","abort","timeoutMs","response","fetch","url","method","headers","buildHeaders","signal","status","ok","Error","payload","json","nextValues","get","Date","now","writeCache","notify","error","onError","clearTimeout","startConfigAutoRefresh","appState","subscription","addEventListener","state","interval","pollIntervalMs","stopPolling","setInterval","remove","clearInterval","getConfigValue","key","Object","prototype","hasOwnProperty","call","fallback","undefined","getConfigBoolean","value","toLowerCase","getConfigNumber","parsed","Number","isNaN","getConfigString","String","getAllConfig","onConfigChange","listener","add","delete","getConfigInfo","keyCount","keys","length","resetConfigForTests","clear","currentEtag","ctx","getContext","Accept","platform","appVersion","osVersion","language","deviceId","userAttributes","JSON","stringify","raw","getString","parse","set","snapshot","forEach"],"sourceRoot":"../../src","sources":["config.ts"],"mappings":"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","ignoreList":[]}
@@ -2,4 +2,5 @@ export * from './PulseUpdates';
2
2
  export * from './types';
3
3
  export { usePulseUpdates } from './usePulseUpdates';
4
4
  export { initializeAssetResolver, updateLocalAssets } from './assetResolver';
5
+ export * from './config';
5
6
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["usePulseUpdates","initializeAssetResolver","updateLocalAssets"],"sourceRoot":"../../src","sources":["index.ts"],"mappings":"AAAA,cAAc,gBAAgB;AAC9B,cAAc,SAAS;AACvB,SAASA,eAAe,QAAQ,mBAAmB;AACnD,SAASC,uBAAuB,EAAEC,iBAAiB,QAAQ,iBAAiB","ignoreList":[]}
1
+ {"version":3,"names":["usePulseUpdates","initializeAssetResolver","updateLocalAssets"],"sourceRoot":"../../src","sources":["index.ts"],"mappings":"AAAA,cAAc,gBAAgB;AAC9B,cAAc,SAAS;AACvB,SAASA,eAAe,QAAQ,mBAAmB;AACnD,SAASC,uBAAuB,EAAEC,iBAAiB,QAAQ,iBAAiB;AAC5E,cAAc,UAAU","ignoreList":[]}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pulse Config — the client half of the Firebase Remote Config replacement.
3
+ *
4
+ * Why this exists: from 2026-09-01 Firebase bills every Remote Config fetch above
5
+ * 100k/day and throttles projects that stay on the free plan. Worse than the bill is
6
+ * the failure mode — the Firebase SDK yields the type's zero value for a key it does
7
+ * not hold, so a throttled fetch reads as "every flag is false" and, for an ads
8
+ * config, quietly turns the revenue off.
9
+ *
10
+ * The shape here deliberately mirrors what the apps already do with Remote Config —
11
+ * defaults, fetch, activate, typed getters — so swapping the source is a change of
12
+ * adapter, not of every call site. Three things are different on purpose:
13
+ *
14
+ * 1. A missing key falls back to the registered default, never to false/0. That is
15
+ * the exact bug this replaces.
16
+ * 2. The last good values are persisted and reloaded at boot, so a failed fetch
17
+ * degrades to yesterday's config rather than to nothing.
18
+ * 3. Fetching is cheap (our server, no per-request price), so it happens on launch,
19
+ * on every return from foreground, and on a timer — with ETag, so an unchanged
20
+ * config costs a 304 with no body.
21
+ */
22
+ export type ConfigValue = boolean | number | string | null | object;
23
+ export interface ConfigStorage {
24
+ getString(key: string): string | null | undefined;
25
+ set(key: string, value: string): void;
26
+ }
27
+ /**
28
+ * Everything the server may ever want to target on. Sent on every request even when
29
+ * no rule uses it yet: the client can only start sending a new field with a store
30
+ * build, so a contract that is incomplete today cannot be completed for months.
31
+ */
32
+ export interface ConfigContext {
33
+ platform?: string;
34
+ appVersion?: string;
35
+ osVersion?: string;
36
+ language?: string;
37
+ deviceId?: string;
38
+ /** Free-form user attributes a rule can compare (registration date, plan, counters). */
39
+ userAttributes?: Record<string, string>;
40
+ }
41
+ export interface ConfigOptions {
42
+ /** Full manifest-style URL, e.g. https://pulse.example.com/pulse/config/esound */
43
+ url: string;
44
+ /** Values used when the server has never been reached, or has no such key. */
45
+ defaults?: Record<string, ConfigValue>;
46
+ /** Persistence for the last good payload. Without it the cache is memory-only. */
47
+ storage?: ConfigStorage;
48
+ /** Read fresh on every request: the country or plan can change between launches. */
49
+ getContext?: () => ConfigContext;
50
+ /** Foreground poll interval. 0 disables polling (launch + resume still fetch). */
51
+ pollIntervalMs?: number;
52
+ /** Network timeout per request. */
53
+ timeoutMs?: number;
54
+ onError?: (error: unknown) => void;
55
+ }
56
+ /**
57
+ * Registers defaults and reloads the last good payload from storage, synchronously,
58
+ * so the very first read at boot already has real values instead of a blank config
59
+ * while the network call is in flight.
60
+ */
61
+ export declare function configureConfig(opts: ConfigOptions): void;
62
+ /** Merge more defaults after configure (a late-loading module registering its own). */
63
+ export declare function setConfigDefaults(more: Record<string, ConfigValue>): void;
64
+ /**
65
+ * Fetch, and adopt the payload if it changed. Returns true when new values were
66
+ * applied — false for "nothing changed" (304) and false for a failure, which are
67
+ * deliberately indistinguishable to callers: in both cases the current values stand.
68
+ * Errors surface through onError instead of rejecting, because no caller should have
69
+ * to wrap a config refresh in a try/catch to keep the app running.
70
+ */
71
+ export declare function fetchConfig(): Promise<boolean>;
72
+ /**
73
+ * Fetch on launch, on every return to the foreground, and on a timer.
74
+ * Returns a function that stops both.
75
+ */
76
+ export declare function startConfigAutoRefresh(appState?: {
77
+ addEventListener: (type: 'change', handler: (state: string) => void) => {
78
+ remove: () => void;
79
+ };
80
+ }): () => void;
81
+ /** The served value, or the registered default. Never a type's zero value. */
82
+ export declare function getConfigValue(key: string): ConfigValue;
83
+ export declare function getConfigBoolean(key: string): boolean;
84
+ export declare function getConfigNumber(key: string): number;
85
+ export declare function getConfigString(key: string): string;
86
+ /** Defaults first, then everything the server sent — what a caller would see key by key. */
87
+ export declare function getAllConfig(): Record<string, ConfigValue>;
88
+ export declare function onConfigChange(listener: (values: Record<string, ConfigValue>) => void): () => void;
89
+ /** For diagnostics: where the current values came from and how old they are. */
90
+ export declare function getConfigInfo(): {
91
+ source: 'defaults' | 'cache' | 'remote';
92
+ fetchedAt: number;
93
+ etag: string | null;
94
+ keyCount: number;
95
+ };
96
+ /** Test seam: drops every piece of module state. */
97
+ export declare function resetConfigForTests(): void;
98
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC;AAEpE,MAAM,WAAW,aAAa;IAC5B,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAClD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACvC,kFAAkF;IAClF,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,oFAAoF;IACpF,UAAU,CAAC,EAAE,MAAM,aAAa,CAAC;IACjC,kFAAkF;IAClF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mCAAmC;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAsBD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAWzD;AAED,uFAAuF;AACvF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,IAAI,CAEzE;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,CA6CpD;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,CAAC,EAAE;IAChD,gBAAgB,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,KAAK;QAAE,MAAM,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC;CAChG,GAAG,MAAM,IAAI,CAiBb;AAWD,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAMvD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAOrD;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQnD;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKnD;AAED,4FAA4F;AAC5F,wBAAgB,YAAY,IAAI,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAE1D;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,KAAK,IAAI,GAAG,MAAM,IAAI,CAGlG;AAED,gFAAgF;AAChF,wBAAgB,aAAa,IAAI;IAC/B,MAAM,EAAE,UAAU,GAAG,OAAO,GAAG,QAAQ,CAAC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAEA;AAED,oDAAoD;AACpD,wBAAgB,mBAAmB,IAAI,IAAI,CAU1C"}
@@ -2,4 +2,5 @@ export * from './PulseUpdates';
2
2
  export * from './types';
3
3
  export { usePulseUpdates } from './usePulseUpdates';
4
4
  export { initializeAssetResolver, updateLocalAssets } from './assetResolver';
5
+ export * from './config';
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAC7E,cAAc,UAAU,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-updates",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "OTA updates for React Native - lightweight alternative to expo-updates",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -777,8 +777,9 @@ async function publish(options) {
777
777
  }
778
778
  logSuccess(`Created release: ${release.id}`);
779
779
 
780
- // Crash-prediction preflight: warn (and abort unless --force) if this release references native
781
- // modules that weren't in the previous good release for this runtime version.
780
+ // Crash-prediction preflight: REFUSE the publish (fail-closed) if this release references native
781
+ // modules that weren't in the previous good release for this runtime version — native code can't ship
782
+ // over the air, so it would crash the installed cohort. Override with --allow-native-change.
782
783
  let preflight = null;
783
784
  try {
784
785
  const pfRes = await fetch(`${config.apiUrl}/api/releases/${release.id}/preflight`, {
@@ -790,17 +791,24 @@ async function publish(options) {
790
791
  }
791
792
  if (preflight) {
792
793
  for (const w of preflight.warnings || []) log(`⚠ ${w}`);
793
- // Only NEW NATIVE modules are risky for OTA (native code can't ship over the air); JS changes
794
- // are always fine. Warn by default; only block when the publisher opts into --strict.
795
- if (!preflight.ok) {
796
- if (options.strict) {
794
+ // Only NEW NATIVE modules are risky for OTA (native code can't ship over the air); JS changes are
795
+ // always fine. FAIL-CLOSED by default: a new native module referenced by this JS bundle that isn't
796
+ // in the previous good build for this runtime-version would CRASH the whole installed cohort on load,
797
+ // so we refuse the publish. Override only with --allow-native-change (you certify the native module
798
+ // is already in the installed binary). `--no-native-check` skips the gate entirely.
799
+ if (!preflight.ok && !options['no-native-check']) {
800
+ const allow = options['allow-native-change'] || options.force;
801
+ if (!allow) {
797
802
  throw new Error(
798
- 'Aborting (--strict): this release references native modules new to it they cannot ship ' +
799
- 'over the air. Ship them in a new native binary first.'
803
+ `Refusing to publish: this release references native module(s) NOT in the previous good build ` +
804
+ `for runtime-version ${config.runtimeVersion}/${config.platform}. Native code cannot ship over ` +
805
+ `the air — this OTA would crash the installed cohort on load. Ship a NEW native binary (and bump ` +
806
+ `the runtime-version) first. If you are certain the native module is already in the installed ` +
807
+ `binary, re-run with --allow-native-change.`
800
808
  );
801
809
  }
802
- log('⚠ Continuing: native-module changes detected (above). Only matters if those native modules ' +
803
- 'are not already in the installed binary. Re-run with --strict to block on this.');
810
+ logWarning('--allow-native-change: publishing despite detected native-module changes. If those ' +
811
+ 'native modules are NOT already in the installed binary, this OTA will crash the cohort.');
804
812
  }
805
813
  }
806
814
 
@@ -989,7 +997,11 @@ ${colors.cyan}Options:${colors.reset}
989
997
  --entry-file <file> Entry file (default: index.ts)
990
998
  --skip-bundle Skip bundle creation (use existing)
991
999
  --dry-run Build + validate locally without creating a release on the server
992
- --strict Abort publish if new native modules are detected (default: warn only)
1000
+ --allow-native-change Publish even though new native modules were detected (you certify they're
1001
+ already in the installed binary). Default is FAIL-CLOSED: a new native module
1002
+ not in the shipped build is refused (it would crash the cohort over the air).
1003
+ --no-native-check Skip the native-mismatch gate entirely (not recommended)
1004
+ --strict (deprecated — blocking on native changes is now the default)
993
1005
  --build <number> Build number (shown in version, e.g., 1.0.0.42)
994
1006
  --message <msg> Release message/notes
995
1007
  --key-id <id> Key id for keygen (default: auto-generated)
package/src/config.ts ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Pulse Config — the client half of the Firebase Remote Config replacement.
3
+ *
4
+ * Why this exists: from 2026-09-01 Firebase bills every Remote Config fetch above
5
+ * 100k/day and throttles projects that stay on the free plan. Worse than the bill is
6
+ * the failure mode — the Firebase SDK yields the type's zero value for a key it does
7
+ * not hold, so a throttled fetch reads as "every flag is false" and, for an ads
8
+ * config, quietly turns the revenue off.
9
+ *
10
+ * The shape here deliberately mirrors what the apps already do with Remote Config —
11
+ * defaults, fetch, activate, typed getters — so swapping the source is a change of
12
+ * adapter, not of every call site. Three things are different on purpose:
13
+ *
14
+ * 1. A missing key falls back to the registered default, never to false/0. That is
15
+ * the exact bug this replaces.
16
+ * 2. The last good values are persisted and reloaded at boot, so a failed fetch
17
+ * degrades to yesterday's config rather than to nothing.
18
+ * 3. Fetching is cheap (our server, no per-request price), so it happens on launch,
19
+ * on every return from foreground, and on a timer — with ETag, so an unchanged
20
+ * config costs a 304 with no body.
21
+ */
22
+
23
+ export type ConfigValue = boolean | number | string | null | object;
24
+
25
+ export interface ConfigStorage {
26
+ getString(key: string): string | null | undefined;
27
+ set(key: string, value: string): void;
28
+ }
29
+
30
+ /**
31
+ * Everything the server may ever want to target on. Sent on every request even when
32
+ * no rule uses it yet: the client can only start sending a new field with a store
33
+ * build, so a contract that is incomplete today cannot be completed for months.
34
+ */
35
+ export interface ConfigContext {
36
+ platform?: string;
37
+ appVersion?: string;
38
+ osVersion?: string;
39
+ language?: string;
40
+ deviceId?: string;
41
+ /** Free-form user attributes a rule can compare (registration date, plan, counters). */
42
+ userAttributes?: Record<string, string>;
43
+ }
44
+
45
+ export interface ConfigOptions {
46
+ /** Full manifest-style URL, e.g. https://pulse.example.com/pulse/config/esound */
47
+ url: string;
48
+ /** Values used when the server has never been reached, or has no such key. */
49
+ defaults?: Record<string, ConfigValue>;
50
+ /** Persistence for the last good payload. Without it the cache is memory-only. */
51
+ storage?: ConfigStorage;
52
+ /** Read fresh on every request: the country or plan can change between launches. */
53
+ getContext?: () => ConfigContext;
54
+ /** Foreground poll interval. 0 disables polling (launch + resume still fetch). */
55
+ pollIntervalMs?: number;
56
+ /** Network timeout per request. */
57
+ timeoutMs?: number;
58
+ onError?: (error: unknown) => void;
59
+ }
60
+
61
+ interface CachedPayload {
62
+ values: Record<string, ConfigValue>;
63
+ etag: string | null;
64
+ fetchedAt: number;
65
+ }
66
+
67
+ const STORAGE_KEY = 'pulse.config.v1';
68
+ const DEFAULT_POLL_MS = 5 * 60 * 1000;
69
+ const DEFAULT_TIMEOUT_MS = 10_000;
70
+
71
+ let options: ConfigOptions | null = null;
72
+ let defaults: Record<string, ConfigValue> = {};
73
+ let values: Record<string, ConfigValue> = {};
74
+ let etag: string | null = null;
75
+ let fetchedAt = 0;
76
+ let source: 'defaults' | 'cache' | 'remote' = 'defaults';
77
+ let inFlight: Promise<boolean> | null = null;
78
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
79
+ const listeners = new Set<(values: Record<string, ConfigValue>) => void>();
80
+
81
+ /**
82
+ * Registers defaults and reloads the last good payload from storage, synchronously,
83
+ * so the very first read at boot already has real values instead of a blank config
84
+ * while the network call is in flight.
85
+ */
86
+ export function configureConfig(opts: ConfigOptions): void {
87
+ options = opts;
88
+ defaults = { ...(opts.defaults ?? {}) };
89
+
90
+ const cached = readCache(opts.storage);
91
+ if (cached) {
92
+ values = cached.values;
93
+ etag = cached.etag;
94
+ fetchedAt = cached.fetchedAt;
95
+ source = 'cache';
96
+ }
97
+ }
98
+
99
+ /** Merge more defaults after configure (a late-loading module registering its own). */
100
+ export function setConfigDefaults(more: Record<string, ConfigValue>): void {
101
+ defaults = { ...defaults, ...more };
102
+ }
103
+
104
+ /**
105
+ * Fetch, and adopt the payload if it changed. Returns true when new values were
106
+ * applied — false for "nothing changed" (304) and false for a failure, which are
107
+ * deliberately indistinguishable to callers: in both cases the current values stand.
108
+ * Errors surface through onError instead of rejecting, because no caller should have
109
+ * to wrap a config refresh in a try/catch to keep the app running.
110
+ */
111
+ export async function fetchConfig(): Promise<boolean> {
112
+ if (!options) return false;
113
+ // Collapse concurrent calls (launch + resume can land together) onto one request.
114
+ if (inFlight) return inFlight;
115
+
116
+ inFlight = (async () => {
117
+ const opts = options!;
118
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
119
+ const timer = controller
120
+ ? setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
121
+ : null;
122
+
123
+ try {
124
+ const response = await fetch(opts.url, {
125
+ method: 'GET',
126
+ headers: buildHeaders(opts, etag),
127
+ signal: controller?.signal,
128
+ });
129
+
130
+ if (response.status === 304) return false;
131
+ if (!response.ok) throw new Error(`Pulse config HTTP ${response.status}`);
132
+
133
+ const payload = await response.json();
134
+ const nextValues = (payload?.values ?? {}) as Record<string, ConfigValue>;
135
+
136
+ values = nextValues;
137
+ etag = response.headers?.get?.('etag') ?? null;
138
+ fetchedAt = Date.now();
139
+ source = 'remote';
140
+
141
+ writeCache(opts.storage, { values, etag, fetchedAt });
142
+ notify();
143
+ return true;
144
+ } catch (error) {
145
+ // A failed refresh must never clear what we have: the whole point of the cache
146
+ // is that an unreachable server degrades to the last good config.
147
+ opts.onError?.(error);
148
+ return false;
149
+ } finally {
150
+ if (timer) clearTimeout(timer);
151
+ inFlight = null;
152
+ }
153
+ })();
154
+
155
+ return inFlight;
156
+ }
157
+
158
+ /**
159
+ * Fetch on launch, on every return to the foreground, and on a timer.
160
+ * Returns a function that stops both.
161
+ */
162
+ export function startConfigAutoRefresh(appState?: {
163
+ addEventListener: (type: 'change', handler: (state: string) => void) => { remove: () => void };
164
+ }): () => void {
165
+ void fetchConfig();
166
+
167
+ const subscription = appState?.addEventListener('change', (state) => {
168
+ if (state === 'active') void fetchConfig();
169
+ });
170
+
171
+ const interval = options?.pollIntervalMs ?? DEFAULT_POLL_MS;
172
+ if (interval > 0) {
173
+ stopPolling();
174
+ pollTimer = setInterval(() => void fetchConfig(), interval);
175
+ }
176
+
177
+ return () => {
178
+ subscription?.remove();
179
+ stopPolling();
180
+ };
181
+ }
182
+
183
+ function stopPolling(): void {
184
+ if (pollTimer) {
185
+ clearInterval(pollTimer);
186
+ pollTimer = null;
187
+ }
188
+ }
189
+
190
+ // ─── Reads ────────────────────────────────────────────────────────────────────
191
+
192
+ /** The served value, or the registered default. Never a type's zero value. */
193
+ export function getConfigValue(key: string): ConfigValue {
194
+ if (Object.prototype.hasOwnProperty.call(values, key) && values[key] !== null) {
195
+ return values[key] as ConfigValue;
196
+ }
197
+ const fallback = defaults[key];
198
+ return fallback === undefined ? null : fallback;
199
+ }
200
+
201
+ export function getConfigBoolean(key: string): boolean {
202
+ const value = getConfigValue(key);
203
+ if (typeof value === 'boolean') return value;
204
+ // A server that sends "true" as a string must not read as false.
205
+ if (typeof value === 'string') return value.toLowerCase() === 'true';
206
+ if (typeof value === 'number') return value !== 0;
207
+ return false;
208
+ }
209
+
210
+ export function getConfigNumber(key: string): number {
211
+ const value = getConfigValue(key);
212
+ if (typeof value === 'number') return value;
213
+ if (typeof value === 'string') {
214
+ const parsed = Number(value);
215
+ if (!Number.isNaN(parsed)) return parsed;
216
+ }
217
+ return 0;
218
+ }
219
+
220
+ export function getConfigString(key: string): string {
221
+ const value = getConfigValue(key);
222
+ if (typeof value === 'string') return value;
223
+ if (value === null || value === undefined) return '';
224
+ return String(value);
225
+ }
226
+
227
+ /** Defaults first, then everything the server sent — what a caller would see key by key. */
228
+ export function getAllConfig(): Record<string, ConfigValue> {
229
+ return { ...defaults, ...values };
230
+ }
231
+
232
+ export function onConfigChange(listener: (values: Record<string, ConfigValue>) => void): () => void {
233
+ listeners.add(listener);
234
+ return () => listeners.delete(listener);
235
+ }
236
+
237
+ /** For diagnostics: where the current values came from and how old they are. */
238
+ export function getConfigInfo(): {
239
+ source: 'defaults' | 'cache' | 'remote';
240
+ fetchedAt: number;
241
+ etag: string | null;
242
+ keyCount: number;
243
+ } {
244
+ return { source, fetchedAt, etag, keyCount: Object.keys(getAllConfig()).length };
245
+ }
246
+
247
+ /** Test seam: drops every piece of module state. */
248
+ export function resetConfigForTests(): void {
249
+ options = null;
250
+ defaults = {};
251
+ values = {};
252
+ etag = null;
253
+ fetchedAt = 0;
254
+ source = 'defaults';
255
+ inFlight = null;
256
+ listeners.clear();
257
+ stopPolling();
258
+ }
259
+
260
+ // ─── Internals ────────────────────────────────────────────────────────────────
261
+
262
+ function buildHeaders(opts: ConfigOptions, currentEtag: string | null): Record<string, string> {
263
+ const ctx = opts.getContext?.() ?? {};
264
+ const headers: Record<string, string> = { Accept: 'application/json' };
265
+
266
+ if (ctx.platform) headers['X-Pulse-Platform'] = ctx.platform;
267
+ if (ctx.appVersion) headers['X-Pulse-App-Version'] = ctx.appVersion;
268
+ if (ctx.osVersion) headers['X-Pulse-Os-Version'] = ctx.osVersion;
269
+ if (ctx.language) headers['X-Pulse-Language'] = ctx.language;
270
+ if (ctx.deviceId) headers['Pulse-Device-Id'] = ctx.deviceId;
271
+ if (ctx.userAttributes && Object.keys(ctx.userAttributes).length > 0) {
272
+ headers['X-Pulse-User-Attributes'] = JSON.stringify(ctx.userAttributes);
273
+ }
274
+ // Country is deliberately absent: the server reads it from the edge, which a client
275
+ // header cannot spoof to opt into a targeted rollout.
276
+
277
+ if (currentEtag) headers['If-None-Match'] = currentEtag;
278
+ return headers;
279
+ }
280
+
281
+ function readCache(storage?: ConfigStorage): CachedPayload | null {
282
+ if (!storage) return null;
283
+ try {
284
+ const raw = storage.getString(STORAGE_KEY);
285
+ if (!raw) return null;
286
+ const parsed = JSON.parse(raw) as CachedPayload;
287
+ if (!parsed || typeof parsed !== 'object' || !parsed.values) return null;
288
+ return parsed;
289
+ } catch {
290
+ // A corrupt cache is not worth a crash at boot: fall back to defaults.
291
+ return null;
292
+ }
293
+ }
294
+
295
+ function writeCache(storage: ConfigStorage | undefined, payload: CachedPayload): void {
296
+ if (!storage) return;
297
+ try {
298
+ storage.set(STORAGE_KEY, JSON.stringify(payload));
299
+ } catch {
300
+ // Persistence is an optimisation; failing to write must not fail the fetch.
301
+ }
302
+ }
303
+
304
+ function notify(): void {
305
+ const snapshot = getAllConfig();
306
+ listeners.forEach((listener) => {
307
+ try {
308
+ listener(snapshot);
309
+ } catch {
310
+ // One bad listener must not stop the others from being told.
311
+ }
312
+ });
313
+ }
package/src/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from './PulseUpdates';
2
2
  export * from './types';
3
3
  export { usePulseUpdates } from './usePulseUpdates';
4
4
  export { initializeAssetResolver, updateLocalAssets } from './assetResolver';
5
+ export * from './config';