@sovaria/sdk 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +248 -2
- package/dist/analytics.d.ts +92 -0
- package/dist/analytics.js +236 -0
- package/dist/client.d.ts +146 -0
- package/dist/client.js +583 -0
- package/dist/collections.d.ts +255 -0
- package/dist/collections.js +569 -0
- package/dist/complete-api.d.ts +54 -0
- package/dist/complete-api.js +71 -0
- package/dist/crashes.d.ts +111 -0
- package/dist/crashes.js +244 -0
- package/dist/crypto.d.ts +37 -0
- package/dist/crypto.js +189 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +258 -0
- package/dist/firestore.d.ts +239 -0
- package/dist/firestore.js +431 -0
- package/dist/generated.d.ts +2462 -0
- package/dist/generated.js +1962 -0
- package/dist/inapp.d.ts +52 -0
- package/dist/inapp.js +48 -0
- package/dist/index.d.ts +3562 -0
- package/dist/index.js +3018 -0
- package/dist/offline.d.ts +92 -0
- package/dist/offline.js +575 -0
- package/dist/passkeys.d.ts +17 -0
- package/dist/passkeys.js +76 -0
- package/dist/perf.d.ts +76 -0
- package/dist/perf.js +212 -0
- package/dist/remote-config.d.ts +87 -0
- package/dist/remote-config.js +182 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.js +1 -0
- package/dist/webpush.d.ts +46 -0
- package/dist/webpush.js +99 -0
- package/package.json +74 -4
package/dist/passkeys.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn (passkey) JSON helpers for the browser: Sovaria sends registration and sign-in options as
|
|
3
|
+
* JSON with base64url strings (py_webauthn `options_to_json`), the browser API wants ArrayBuffers, and
|
|
4
|
+
* Sovaria expects the resulting credential back as JSON again. No dependencies.
|
|
5
|
+
*/
|
|
6
|
+
export function base64urlToBuffer(value) {
|
|
7
|
+
const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
8
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
9
|
+
const binary = atob(padded);
|
|
10
|
+
const bytes = new Uint8Array(binary.length);
|
|
11
|
+
for (let i = 0; i < binary.length; i += 1)
|
|
12
|
+
bytes[i] = binary.charCodeAt(i);
|
|
13
|
+
return bytes.buffer;
|
|
14
|
+
}
|
|
15
|
+
export function bufferToBase64url(value) {
|
|
16
|
+
const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
17
|
+
let binary = '';
|
|
18
|
+
for (let i = 0; i < bytes.length; i += 1)
|
|
19
|
+
binary += String.fromCharCode(bytes[i]);
|
|
20
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
21
|
+
}
|
|
22
|
+
const descriptors = (list) => Array.isArray(list)
|
|
23
|
+
? list.map((d) => ({ ...d, id: base64urlToBuffer(String(d.id)) }))
|
|
24
|
+
: undefined;
|
|
25
|
+
/** Registration options JSON → `PublicKeyCredentialCreationOptions`. */
|
|
26
|
+
export function creationOptionsFromJSON(json) {
|
|
27
|
+
const options = (json.publicKey ?? json);
|
|
28
|
+
const user = options.user;
|
|
29
|
+
return {
|
|
30
|
+
...options,
|
|
31
|
+
challenge: base64urlToBuffer(String(options.challenge)),
|
|
32
|
+
user: { ...user, id: base64urlToBuffer(String(user.id)) },
|
|
33
|
+
excludeCredentials: descriptors(options.excludeCredentials),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Sign-in options JSON → `PublicKeyCredentialRequestOptions`. */
|
|
37
|
+
export function requestOptionsFromJSON(json) {
|
|
38
|
+
const options = (json.publicKey ?? json);
|
|
39
|
+
return {
|
|
40
|
+
...options,
|
|
41
|
+
challenge: base64urlToBuffer(String(options.challenge)),
|
|
42
|
+
allowCredentials: descriptors(options.allowCredentials),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** A `PublicKeyCredential` from `navigator.credentials.create()` or `.get()` → the JSON Sovaria verifies. */
|
|
46
|
+
export function credentialToJSON(credential) {
|
|
47
|
+
const response = credential.response;
|
|
48
|
+
const out = { clientDataJSON: bufferToBase64url(response.clientDataJSON) };
|
|
49
|
+
if ('attestationObject' in response) {
|
|
50
|
+
const attestation = response;
|
|
51
|
+
out.attestationObject = bufferToBase64url(attestation.attestationObject);
|
|
52
|
+
const transports = typeof attestation.getTransports === 'function' ? attestation.getTransports() : [];
|
|
53
|
+
// `transports` is the WebAuthn JSON name; Sovaria also reads `getTransports` to remember them.
|
|
54
|
+
out.transports = transports;
|
|
55
|
+
out.getTransports = transports;
|
|
56
|
+
}
|
|
57
|
+
if ('authenticatorData' in response) {
|
|
58
|
+
const assertion = response;
|
|
59
|
+
out.authenticatorData = bufferToBase64url(assertion.authenticatorData);
|
|
60
|
+
out.signature = bufferToBase64url(assertion.signature);
|
|
61
|
+
if (assertion.userHandle)
|
|
62
|
+
out.userHandle = bufferToBase64url(assertion.userHandle);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
id: credential.id,
|
|
66
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
67
|
+
type: credential.type,
|
|
68
|
+
response: out,
|
|
69
|
+
authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
|
|
70
|
+
clientExtensionResults: typeof credential.getClientExtensionResults === 'function' ? credential.getClientExtensionResults() : {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** True when this environment can create and use passkeys. */
|
|
74
|
+
export function passkeysSupported() {
|
|
75
|
+
return typeof globalThis.PublicKeyCredential !== 'undefined' && Boolean(globalThis.navigator?.credentials);
|
|
76
|
+
}
|
package/dist/perf.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client performance monitoring: what your users actually wait for, joined to the server's side of each request.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* sov.perf.install({ release: '1.4.2' }) // web vitals + timing of every Sovaria request
|
|
6
|
+
* await sov.perf.trace('checkout', () => pay(cart)) // your own timed step
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* - **Web vitals** (browsers, PerformanceObserver): LCP, INP (slowest interaction), CLS, FCP and TTFB, once per
|
|
10
|
+
* page load, tagged with the page's route (`/orders/:id`; ids are collapsed on the server).
|
|
11
|
+
* - **Network spans**: every request the SDK makes, with its method, route, status and duration. Each carries
|
|
12
|
+
* the W3C `traceparent` the SDK sent, so in the console a slow call opens the server trace for that request,
|
|
13
|
+
* and each route's client time sits next to the gateway's server time.
|
|
14
|
+
* - **Custom traces**: `trace(name, fn)` or `start(name)` → `end()`.
|
|
15
|
+
*
|
|
16
|
+
* Measurements are batched (every 10 s, at most 100 per request, and when the page is hidden) and count
|
|
17
|
+
* against the plan's `obs.perf_events_month`; `sampleRate` sends a share of page loads.
|
|
18
|
+
*/
|
|
19
|
+
export type PerfKind = 'vital' | 'network' | 'app_start' | 'screen' | 'trace';
|
|
20
|
+
export interface PerfEvent {
|
|
21
|
+
kind: PerfKind;
|
|
22
|
+
name: string;
|
|
23
|
+
value: number;
|
|
24
|
+
route?: string;
|
|
25
|
+
release?: string;
|
|
26
|
+
environment?: string;
|
|
27
|
+
platform: string;
|
|
28
|
+
trace_id?: string;
|
|
29
|
+
session_id?: string;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
attributes?: Record<string, string | number | boolean | null>;
|
|
32
|
+
}
|
|
33
|
+
export interface PerfOptions {
|
|
34
|
+
release?: string;
|
|
35
|
+
environment?: string;
|
|
36
|
+
/** 0..1: share of page loads (or app launches) that send measurements (default 1). */
|
|
37
|
+
sampleRate?: number;
|
|
38
|
+
/** Time the SDK's own requests (default true). */
|
|
39
|
+
network?: boolean;
|
|
40
|
+
/** Web vitals in browsers (default true). */
|
|
41
|
+
webVitals?: boolean;
|
|
42
|
+
/** Change or drop (return null) a measurement before it is queued. */
|
|
43
|
+
beforeSend?: (event: PerfEvent) => PerfEvent | null;
|
|
44
|
+
}
|
|
45
|
+
type Sender = (events: PerfEvent[]) => Promise<unknown>;
|
|
46
|
+
export declare class PerfMonitor {
|
|
47
|
+
private readonly send;
|
|
48
|
+
private readonly session;
|
|
49
|
+
private options;
|
|
50
|
+
private queue;
|
|
51
|
+
private timer?;
|
|
52
|
+
private uninstallers;
|
|
53
|
+
private sampled;
|
|
54
|
+
installed: boolean;
|
|
55
|
+
constructor(send: Sender, session?: () => string | undefined);
|
|
56
|
+
install(options?: PerfOptions): () => void;
|
|
57
|
+
uninstall(): void;
|
|
58
|
+
/** Whether the SDK should time its own requests. */
|
|
59
|
+
get timesNetwork(): boolean;
|
|
60
|
+
/** Queue one measurement (any kind). Returns false when not installed, not sampled or dropped. */
|
|
61
|
+
record(event: Omit<PerfEvent, 'platform' | 'timestamp' | 'release' | 'environment'> & Partial<PerfEvent>): boolean;
|
|
62
|
+
/** A network span for one request (called by the client for its own requests). */
|
|
63
|
+
network(method: string, path: string, status: number, durationMs: number, traceId?: string): void;
|
|
64
|
+
/** Time `fn` as a custom trace called `name` (also when it throws). */
|
|
65
|
+
trace<T>(name: string, fn: () => Promise<T> | T, attributes?: PerfEvent['attributes']): Promise<T>;
|
|
66
|
+
/** Start a custom trace; call the returned function to end it. */
|
|
67
|
+
start(name: string, attributes?: PerfEvent['attributes']): () => void;
|
|
68
|
+
/** Send queued measurements now. */
|
|
69
|
+
flush(): Promise<void>;
|
|
70
|
+
private vitals;
|
|
71
|
+
private observeVitals;
|
|
72
|
+
/** LCP, CLS and INP are final when the page is hidden: report them once per page load. */
|
|
73
|
+
private reportPageVitals;
|
|
74
|
+
}
|
|
75
|
+
export declare function currentRoute(): string | undefined;
|
|
76
|
+
export {};
|
package/dist/perf.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client performance monitoring: what your users actually wait for, joined to the server's side of each request.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* sov.perf.install({ release: '1.4.2' }) // web vitals + timing of every Sovaria request
|
|
6
|
+
* await sov.perf.trace('checkout', () => pay(cart)) // your own timed step
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* - **Web vitals** (browsers, PerformanceObserver): LCP, INP (slowest interaction), CLS, FCP and TTFB, once per
|
|
10
|
+
* page load, tagged with the page's route (`/orders/:id`; ids are collapsed on the server).
|
|
11
|
+
* - **Network spans**: every request the SDK makes, with its method, route, status and duration. Each carries
|
|
12
|
+
* the W3C `traceparent` the SDK sent, so in the console a slow call opens the server trace for that request,
|
|
13
|
+
* and each route's client time sits next to the gateway's server time.
|
|
14
|
+
* - **Custom traces**: `trace(name, fn)` or `start(name)` → `end()`.
|
|
15
|
+
*
|
|
16
|
+
* Measurements are batched (every 10 s, at most 100 per request, and when the page is hidden) and count
|
|
17
|
+
* against the plan's `obs.perf_events_month`; `sampleRate` sends a share of page loads.
|
|
18
|
+
*/
|
|
19
|
+
const FLUSH_MS = 10_000;
|
|
20
|
+
const MAX_QUEUE = 500;
|
|
21
|
+
export class PerfMonitor {
|
|
22
|
+
send;
|
|
23
|
+
session;
|
|
24
|
+
options = {};
|
|
25
|
+
queue = [];
|
|
26
|
+
timer;
|
|
27
|
+
uninstallers = [];
|
|
28
|
+
sampled = true;
|
|
29
|
+
installed = false;
|
|
30
|
+
constructor(send, session = () => undefined) {
|
|
31
|
+
this.send = send;
|
|
32
|
+
this.session = session;
|
|
33
|
+
}
|
|
34
|
+
install(options = {}) {
|
|
35
|
+
this.options = options;
|
|
36
|
+
if (this.installed)
|
|
37
|
+
return () => this.uninstall();
|
|
38
|
+
this.installed = true;
|
|
39
|
+
this.sampled = Math.random() < (options.sampleRate ?? 1);
|
|
40
|
+
const g = globalThis;
|
|
41
|
+
if (options.webVitals !== false && typeof g.PerformanceObserver === 'function')
|
|
42
|
+
this.observeVitals(g);
|
|
43
|
+
if (typeof g.addEventListener === 'function') {
|
|
44
|
+
const onHide = () => {
|
|
45
|
+
if (g.document?.visibilityState === 'hidden' || !g.document)
|
|
46
|
+
this.reportPageVitals();
|
|
47
|
+
void this.flush();
|
|
48
|
+
};
|
|
49
|
+
g.addEventListener('visibilitychange', onHide);
|
|
50
|
+
g.addEventListener('pagehide', onHide);
|
|
51
|
+
this.uninstallers.push(() => {
|
|
52
|
+
g.removeEventListener?.('visibilitychange', onHide);
|
|
53
|
+
g.removeEventListener?.('pagehide', onHide);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
this.timer = setInterval(() => void this.flush(), FLUSH_MS);
|
|
57
|
+
this.timer.unref?.();
|
|
58
|
+
return () => this.uninstall();
|
|
59
|
+
}
|
|
60
|
+
uninstall() {
|
|
61
|
+
for (const off of this.uninstallers.splice(0))
|
|
62
|
+
off();
|
|
63
|
+
if (this.timer)
|
|
64
|
+
clearInterval(this.timer);
|
|
65
|
+
this.timer = undefined;
|
|
66
|
+
this.installed = false;
|
|
67
|
+
}
|
|
68
|
+
/** Whether the SDK should time its own requests. */
|
|
69
|
+
get timesNetwork() {
|
|
70
|
+
return this.installed && this.sampled && this.options.network !== false;
|
|
71
|
+
}
|
|
72
|
+
/** Queue one measurement (any kind). Returns false when not installed, not sampled or dropped. */
|
|
73
|
+
record(event) {
|
|
74
|
+
if (!this.installed || !this.sampled || !Number.isFinite(event.value) || event.value < 0)
|
|
75
|
+
return false;
|
|
76
|
+
let full = {
|
|
77
|
+
platform: detectPlatform(), release: this.options.release, environment: this.options.environment,
|
|
78
|
+
route: event.route ?? currentRoute(), session_id: this.session(), timestamp: new Date().toISOString(), ...event,
|
|
79
|
+
value: Math.round(event.value * 1000) / 1000,
|
|
80
|
+
};
|
|
81
|
+
if (this.options.beforeSend)
|
|
82
|
+
full = this.options.beforeSend(full);
|
|
83
|
+
if (!full)
|
|
84
|
+
return false;
|
|
85
|
+
if (this.queue.length >= MAX_QUEUE)
|
|
86
|
+
this.queue.shift();
|
|
87
|
+
this.queue.push(full);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** A network span for one request (called by the client for its own requests). */
|
|
91
|
+
network(method, path, status, durationMs, traceId) {
|
|
92
|
+
this.record({ kind: 'network', name: `${method.toUpperCase()} ${path.split('?')[0]}`, value: durationMs, trace_id: traceId,
|
|
93
|
+
attributes: { method: method.toUpperCase(), url: path.split('?')[0], status } });
|
|
94
|
+
}
|
|
95
|
+
/** Time `fn` as a custom trace called `name` (also when it throws). */
|
|
96
|
+
async trace(name, fn, attributes) {
|
|
97
|
+
const end = this.start(name, attributes);
|
|
98
|
+
try {
|
|
99
|
+
return await fn();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
end();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Start a custom trace; call the returned function to end it. */
|
|
106
|
+
start(name, attributes) {
|
|
107
|
+
const t0 = now();
|
|
108
|
+
let done = false;
|
|
109
|
+
return () => {
|
|
110
|
+
if (done)
|
|
111
|
+
return;
|
|
112
|
+
done = true;
|
|
113
|
+
this.record({ kind: 'trace', name, value: now() - t0, attributes });
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Send queued measurements now. */
|
|
117
|
+
async flush() {
|
|
118
|
+
while (this.queue.length) {
|
|
119
|
+
const batch = this.queue.splice(0, 100);
|
|
120
|
+
try {
|
|
121
|
+
await this.send(batch);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return; // never let monitoring break the app; the batch is dropped
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// ── web vitals ──────────────────────────────────────────────────────────
|
|
129
|
+
vitals = { cls: 0, reported: false };
|
|
130
|
+
observeVitals(g) {
|
|
131
|
+
const observe = (type, cb, extra = {}) => {
|
|
132
|
+
try {
|
|
133
|
+
const po = new g.PerformanceObserver((list) => cb(list.getEntries()));
|
|
134
|
+
po.observe({ type, buffered: true, ...extra });
|
|
135
|
+
this.uninstallers.push(() => po.disconnect());
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* not supported in this browser */
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
observe('largest-contentful-paint', (entries) => {
|
|
142
|
+
const last = entries[entries.length - 1];
|
|
143
|
+
if (last)
|
|
144
|
+
this.vitals.lcp = last.startTime;
|
|
145
|
+
});
|
|
146
|
+
let sessionValue = 0;
|
|
147
|
+
let sessionEntries = [];
|
|
148
|
+
observe('layout-shift', (entries) => {
|
|
149
|
+
for (const e of entries) {
|
|
150
|
+
if (e.hadRecentInput)
|
|
151
|
+
continue;
|
|
152
|
+
const first = sessionEntries[0];
|
|
153
|
+
const last = sessionEntries[sessionEntries.length - 1];
|
|
154
|
+
if (sessionValue && first && last && e.startTime - last.startTime < 1000 && e.startTime - first.startTime < 5000) {
|
|
155
|
+
sessionValue += e.value;
|
|
156
|
+
sessionEntries.push(e);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
sessionValue = e.value;
|
|
160
|
+
sessionEntries = [e];
|
|
161
|
+
}
|
|
162
|
+
this.vitals.cls = Math.max(this.vitals.cls, sessionValue);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
observe('event', (entries) => {
|
|
166
|
+
for (const e of entries) {
|
|
167
|
+
if (!e.interactionId)
|
|
168
|
+
continue;
|
|
169
|
+
this.vitals.inp = Math.max(this.vitals.inp ?? 0, e.duration);
|
|
170
|
+
}
|
|
171
|
+
}, { durationThreshold: 40 });
|
|
172
|
+
observe('paint', (entries) => {
|
|
173
|
+
for (const e of entries)
|
|
174
|
+
if (e.name === 'first-contentful-paint')
|
|
175
|
+
this.record({ kind: 'vital', name: 'FCP', value: e.startTime });
|
|
176
|
+
});
|
|
177
|
+
observe('navigation', (entries) => {
|
|
178
|
+
const nav = entries[0];
|
|
179
|
+
if (nav && nav.responseStart > 0)
|
|
180
|
+
this.record({ kind: 'vital', name: 'TTFB', value: nav.responseStart - (nav.activationStart || 0) });
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** LCP, CLS and INP are final when the page is hidden: report them once per page load. */
|
|
184
|
+
reportPageVitals() {
|
|
185
|
+
if (this.vitals.reported)
|
|
186
|
+
return;
|
|
187
|
+
this.vitals.reported = true;
|
|
188
|
+
if (this.vitals.lcp !== undefined)
|
|
189
|
+
this.record({ kind: 'vital', name: 'LCP', value: this.vitals.lcp });
|
|
190
|
+
this.record({ kind: 'vital', name: 'CLS', value: this.vitals.cls });
|
|
191
|
+
if (this.vitals.inp !== undefined)
|
|
192
|
+
this.record({ kind: 'vital', name: 'INP', value: this.vitals.inp });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function now() {
|
|
196
|
+
const p = globalThis.performance;
|
|
197
|
+
return p?.now ? p.now() : Date.now();
|
|
198
|
+
}
|
|
199
|
+
export function currentRoute() {
|
|
200
|
+
const g = globalThis;
|
|
201
|
+
return typeof g.location?.pathname === 'string' ? g.location.pathname : undefined;
|
|
202
|
+
}
|
|
203
|
+
function detectPlatform() {
|
|
204
|
+
const g = globalThis;
|
|
205
|
+
if (g.navigator?.product === 'ReactNative')
|
|
206
|
+
return 'react-native';
|
|
207
|
+
if (typeof g.window !== 'undefined' && typeof g.document !== 'undefined')
|
|
208
|
+
return 'web';
|
|
209
|
+
if (g.process?.versions?.node)
|
|
210
|
+
return 'node';
|
|
211
|
+
return 'javascript';
|
|
212
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote config and feature flags: values you change in the console (Remote config) without shipping an app.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* sov.config.setDefaults({ checkout_v2: false, banner: 'Welcome' })
|
|
6
|
+
* await sov.config.fetchAndActivate() // at most every 12 h unless a publish signal arrives
|
|
7
|
+
* if (sov.config.getBool('checkout_v2')) showNewCheckout()
|
|
8
|
+
* const off = sov.config.onUpdate((keys) => rerender(keys)) // live: Pulse tells the app a version was published
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The server evaluates targeting (platform, app version, country, language, your attributes, audiences, percent
|
|
12
|
+
* rollouts, experiments) and returns only this user's values — never the rules. Responses carry an ETag, so an
|
|
13
|
+
* unchanged config costs a 304. Activated values persist in the storage you pass, so the app starts with the last
|
|
14
|
+
* values even offline. Reading a value that's part of an experiment logs one `$exposure` analytics event.
|
|
15
|
+
*/
|
|
16
|
+
import type { Analytics } from './analytics.js';
|
|
17
|
+
import type { OfflineStorage } from './offline.js';
|
|
18
|
+
export type ConfigValue = string | number | boolean | null | Record<string, unknown> | unknown[];
|
|
19
|
+
export interface RemoteConfigSettings {
|
|
20
|
+
/** Minimum time between network fetches (default 12 h, like Firebase). A publish signal bypasses it. */
|
|
21
|
+
minimumFetchIntervalMs?: number;
|
|
22
|
+
storage?: OfflineStorage;
|
|
23
|
+
/** Targeting attributes (`props.<name>` in conditions), e.g. { plan: 'pro', beta: true }. */
|
|
24
|
+
attributes?: Record<string, string | number | boolean | null>;
|
|
25
|
+
appVersion?: string;
|
|
26
|
+
platform?: string;
|
|
27
|
+
language?: string;
|
|
28
|
+
/** Listen for publishes over realtime (default true when the plan has config.realtime). */
|
|
29
|
+
realtime?: boolean;
|
|
30
|
+
}
|
|
31
|
+
interface FetchResult {
|
|
32
|
+
version: number;
|
|
33
|
+
values: Record<string, ConfigValue>;
|
|
34
|
+
experiments: Record<string, {
|
|
35
|
+
id: string;
|
|
36
|
+
variant: string;
|
|
37
|
+
}>;
|
|
38
|
+
etag: string;
|
|
39
|
+
realtime_channel?: string | null;
|
|
40
|
+
}
|
|
41
|
+
type Fetcher = (query: Record<string, string>, etag?: string) => Promise<{
|
|
42
|
+
status: 200 | 304;
|
|
43
|
+
body?: FetchResult;
|
|
44
|
+
}>;
|
|
45
|
+
type Listen = (channel: string, onMessage: () => void) => {
|
|
46
|
+
close: () => void;
|
|
47
|
+
};
|
|
48
|
+
export declare class RemoteConfig {
|
|
49
|
+
private readonly fetcher;
|
|
50
|
+
private readonly analytics;
|
|
51
|
+
private readonly listen?;
|
|
52
|
+
private defaults;
|
|
53
|
+
private active;
|
|
54
|
+
private fetched;
|
|
55
|
+
private lastFetch;
|
|
56
|
+
private settingsValue;
|
|
57
|
+
private listeners;
|
|
58
|
+
private channel?;
|
|
59
|
+
private loaded?;
|
|
60
|
+
constructor(fetcher: Fetcher, analytics: Analytics, listen?: Listen | undefined);
|
|
61
|
+
settings(s: RemoteConfigSettings): void;
|
|
62
|
+
setDefaults(defaults: Record<string, ConfigValue>): void;
|
|
63
|
+
private restore;
|
|
64
|
+
/** Fetch from the server (respecting the minimum interval unless `force`). True when new values arrived. */
|
|
65
|
+
fetch(opts?: {
|
|
66
|
+
force?: boolean;
|
|
67
|
+
}): Promise<boolean>;
|
|
68
|
+
/** Make fetched values the ones `get*` returns. True when something changed. */
|
|
69
|
+
activate(): Promise<boolean>;
|
|
70
|
+
fetchAndActivate(opts?: {
|
|
71
|
+
force?: boolean;
|
|
72
|
+
}): Promise<boolean>;
|
|
73
|
+
private ensureListening;
|
|
74
|
+
/** Called with the changed keys whenever new values are activated (after a publish, live). */
|
|
75
|
+
onUpdate(cb: (keys: string[]) => void): () => void;
|
|
76
|
+
private read;
|
|
77
|
+
getValue(key: string, fallback?: ConfigValue): ConfigValue | undefined;
|
|
78
|
+
getBool(key: string, fallback?: boolean): boolean;
|
|
79
|
+
getString(key: string, fallback?: string): string;
|
|
80
|
+
getNumber(key: string, fallback?: number): number;
|
|
81
|
+
getJson<T = unknown>(key: string, fallback: T): T;
|
|
82
|
+
/** Every key with its current value (defaults included). Doesn't log exposures. */
|
|
83
|
+
getAll(): Record<string, ConfigValue>;
|
|
84
|
+
/** The published version these values came from (0 before the first fetch). */
|
|
85
|
+
get version(): number;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote config and feature flags: values you change in the console (Remote config) without shipping an app.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* sov.config.setDefaults({ checkout_v2: false, banner: 'Welcome' })
|
|
6
|
+
* await sov.config.fetchAndActivate() // at most every 12 h unless a publish signal arrives
|
|
7
|
+
* if (sov.config.getBool('checkout_v2')) showNewCheckout()
|
|
8
|
+
* const off = sov.config.onUpdate((keys) => rerender(keys)) // live: Pulse tells the app a version was published
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The server evaluates targeting (platform, app version, country, language, your attributes, audiences, percent
|
|
12
|
+
* rollouts, experiments) and returns only this user's values — never the rules. Responses carry an ETag, so an
|
|
13
|
+
* unchanged config costs a 304. Activated values persist in the storage you pass, so the app starts with the last
|
|
14
|
+
* values even offline. Reading a value that's part of an experiment logs one `$exposure` analytics event.
|
|
15
|
+
*/
|
|
16
|
+
const KEY = 'sovaria.config.active';
|
|
17
|
+
const TWELVE_HOURS = 12 * 60 * 60 * 1000;
|
|
18
|
+
export class RemoteConfig {
|
|
19
|
+
fetcher;
|
|
20
|
+
analytics;
|
|
21
|
+
listen;
|
|
22
|
+
defaults = {};
|
|
23
|
+
active = null;
|
|
24
|
+
fetched = null;
|
|
25
|
+
lastFetch = 0;
|
|
26
|
+
settingsValue = {};
|
|
27
|
+
listeners = new Set();
|
|
28
|
+
channel;
|
|
29
|
+
loaded;
|
|
30
|
+
constructor(fetcher, analytics, listen) {
|
|
31
|
+
this.fetcher = fetcher;
|
|
32
|
+
this.analytics = analytics;
|
|
33
|
+
this.listen = listen;
|
|
34
|
+
}
|
|
35
|
+
settings(s) {
|
|
36
|
+
this.settingsValue = { ...this.settingsValue, ...s };
|
|
37
|
+
this.loaded = this.restore();
|
|
38
|
+
}
|
|
39
|
+
setDefaults(defaults) {
|
|
40
|
+
this.defaults = { ...defaults };
|
|
41
|
+
}
|
|
42
|
+
async restore() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = await this.settingsValue.storage?.get(KEY);
|
|
45
|
+
if (raw && !this.active)
|
|
46
|
+
this.active = JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* best effort */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Fetch from the server (respecting the minimum interval unless `force`). True when new values arrived. */
|
|
53
|
+
async fetch(opts = {}) {
|
|
54
|
+
await this.loaded;
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const interval = this.settingsValue.minimumFetchIntervalMs ?? TWELVE_HOURS;
|
|
57
|
+
if (!opts.force && this.lastFetch && now - this.lastFetch < interval)
|
|
58
|
+
return false;
|
|
59
|
+
const s = this.settingsValue;
|
|
60
|
+
const query = { anon_id: this.analytics.anonId };
|
|
61
|
+
if (s.platform)
|
|
62
|
+
query.platform = s.platform;
|
|
63
|
+
if (s.appVersion)
|
|
64
|
+
query.app_version = s.appVersion;
|
|
65
|
+
if (s.language)
|
|
66
|
+
query.language = s.language;
|
|
67
|
+
if (s.attributes && Object.keys(s.attributes).length)
|
|
68
|
+
query.attrs = JSON.stringify(s.attributes);
|
|
69
|
+
const res = await this.fetcher(query, (this.fetched ?? this.active)?.etag);
|
|
70
|
+
this.lastFetch = now;
|
|
71
|
+
if (res.status === 304 || !res.body)
|
|
72
|
+
return false;
|
|
73
|
+
this.fetched = res.body;
|
|
74
|
+
if (res.body.realtime_channel && s.realtime !== false)
|
|
75
|
+
this.ensureListening(res.body.realtime_channel);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
/** Make fetched values the ones `get*` returns. True when something changed. */
|
|
79
|
+
async activate() {
|
|
80
|
+
if (!this.fetched || this.fetched.etag === this.active?.etag)
|
|
81
|
+
return false;
|
|
82
|
+
const before = this.active?.values ?? {};
|
|
83
|
+
this.active = this.fetched;
|
|
84
|
+
try {
|
|
85
|
+
await this.settingsValue.storage?.set(KEY, JSON.stringify(this.active));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* best effort */
|
|
89
|
+
}
|
|
90
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(this.active.values)]);
|
|
91
|
+
const changed = [...keys].filter((k) => JSON.stringify(before[k]) !== JSON.stringify(this.active.values[k]));
|
|
92
|
+
if (changed.length)
|
|
93
|
+
for (const cb of this.listeners)
|
|
94
|
+
cb(changed);
|
|
95
|
+
return changed.length > 0;
|
|
96
|
+
}
|
|
97
|
+
async fetchAndActivate(opts = {}) {
|
|
98
|
+
try {
|
|
99
|
+
await this.fetch(opts);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false; // offline: keep the last activated values (or defaults)
|
|
103
|
+
}
|
|
104
|
+
return this.activate();
|
|
105
|
+
}
|
|
106
|
+
ensureListening(channel) {
|
|
107
|
+
if (this.channel || !this.listen)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
this.channel = this.listen(channel, () => void this.fetchAndActivate({ force: true }));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* realtime is optional */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Called with the changed keys whenever new values are activated (after a publish, live). */
|
|
117
|
+
onUpdate(cb) {
|
|
118
|
+
this.listeners.add(cb);
|
|
119
|
+
return () => {
|
|
120
|
+
this.listeners.delete(cb);
|
|
121
|
+
if (!this.listeners.size) {
|
|
122
|
+
this.channel?.close();
|
|
123
|
+
this.channel = undefined;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
read(key) {
|
|
128
|
+
const active = this.active;
|
|
129
|
+
if (active && key in active.values) {
|
|
130
|
+
const exp = active.experiments?.[key];
|
|
131
|
+
if (exp)
|
|
132
|
+
this.analytics.trackExposure(exp.id, exp.variant);
|
|
133
|
+
return active.values[key];
|
|
134
|
+
}
|
|
135
|
+
return this.defaults[key];
|
|
136
|
+
}
|
|
137
|
+
getValue(key, fallback) {
|
|
138
|
+
const v = this.read(key);
|
|
139
|
+
return v === undefined || v === null ? fallback : v;
|
|
140
|
+
}
|
|
141
|
+
getBool(key, fallback = false) {
|
|
142
|
+
const v = this.read(key);
|
|
143
|
+
if (typeof v === 'boolean')
|
|
144
|
+
return v;
|
|
145
|
+
if (v === 'true' || v === 1)
|
|
146
|
+
return true;
|
|
147
|
+
if (v === 'false' || v === 0)
|
|
148
|
+
return false;
|
|
149
|
+
return fallback;
|
|
150
|
+
}
|
|
151
|
+
getString(key, fallback = '') {
|
|
152
|
+
const v = this.read(key);
|
|
153
|
+
return v === undefined || v === null ? fallback : typeof v === 'string' ? v : JSON.stringify(v);
|
|
154
|
+
}
|
|
155
|
+
getNumber(key, fallback = 0) {
|
|
156
|
+
const v = this.read(key);
|
|
157
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
|
|
158
|
+
return Number.isFinite(n) ? n : fallback;
|
|
159
|
+
}
|
|
160
|
+
getJson(key, fallback) {
|
|
161
|
+
const v = this.read(key);
|
|
162
|
+
if (v === undefined || v === null)
|
|
163
|
+
return fallback;
|
|
164
|
+
if (typeof v === 'string') {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(v);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return fallback;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return v;
|
|
173
|
+
}
|
|
174
|
+
/** Every key with its current value (defaults included). Doesn't log exposures. */
|
|
175
|
+
getAll() {
|
|
176
|
+
return { ...this.defaults, ...(this.active?.values ?? {}) };
|
|
177
|
+
}
|
|
178
|
+
/** The published version these values came from (0 before the first fetch). */
|
|
179
|
+
get version() {
|
|
180
|
+
return this.active?.version ?? 0;
|
|
181
|
+
}
|
|
182
|
+
}
|