@shelbyb/daywalker-sdk 0.1.4 → 0.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 +23 -0
- package/dist/analytics/index.d.ts +99 -0
- package/dist/analytics/index.js +352 -0
- package/dist/flags/client.d.ts +101 -0
- package/dist/flags/client.js +293 -0
- package/dist/flags/common/events.d.ts +59 -0
- package/dist/flags/common/events.js +178 -0
- package/dist/flags/common/index.d.ts +5 -0
- package/dist/flags/common/index.js +12 -0
- package/dist/flags/common/sse.d.ts +62 -0
- package/dist/flags/common/sse.js +183 -0
- package/dist/flags/engine/bucket.d.ts +21 -0
- package/dist/flags/engine/bucket.js +41 -0
- package/dist/flags/engine/context.d.ts +30 -0
- package/dist/flags/engine/context.js +80 -0
- package/dist/flags/engine/evaluate.d.ts +54 -0
- package/dist/flags/engine/evaluate.js +177 -0
- package/dist/flags/engine/index.d.ts +7 -0
- package/dist/flags/engine/index.js +7 -0
- package/dist/flags/engine/operators.d.ts +5 -0
- package/dist/flags/engine/operators.js +89 -0
- package/dist/flags/engine/semver.d.ts +13 -0
- package/dist/flags/engine/semver.js +35 -0
- package/dist/flags/engine/sha1.d.ts +6 -0
- package/dist/flags/engine/sha1.js +43 -0
- package/dist/flags/engine/types.d.ts +120 -0
- package/dist/flags/engine/types.js +17 -0
- package/dist/flags/node.d.ts +97 -0
- package/dist/flags/node.js +260 -0
- package/dist/flags/react.d.ts +27 -0
- package/dist/flags/react.js +62 -0
- package/dist/openfeature/server.d.ts +26 -0
- package/dist/openfeature/server.js +42 -0
- package/dist/openfeature/shared.d.ts +19 -0
- package/dist/openfeature/shared.js +40 -0
- package/dist/openfeature/web.d.ts +29 -0
- package/dist/openfeature/web.js +50 -0
- package/docs/analytics.md +32 -0
- package/docs/flags-node.md +49 -0
- package/docs/flags-react.md +35 -0
- package/docs/flags.md +53 -0
- package/docs/openfeature.md +30 -0
- package/package.json +61 -6
package/README.md
CHANGED
|
@@ -220,6 +220,29 @@ The exact signature base and test cases are verified against [`test-vectors/sign
|
|
|
220
220
|
|
|
221
221
|
---
|
|
222
222
|
|
|
223
|
+
## DevPulse SDKs (one package)
|
|
224
|
+
|
|
225
|
+
The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable and has zero dependencies; `react` and the OpenFeature SDKs are optional peer dependencies used only by their subpaths.
|
|
226
|
+
|
|
227
|
+
| Import | What it is | Docs |
|
|
228
|
+
|---|---|---|
|
|
229
|
+
| `@shelbyb/daywalker-sdk/flags` | Feature flags for browsers and JS clients, real-time over SSE | [docs/flags.md](./docs/flags.md) |
|
|
230
|
+
| `@shelbyb/daywalker-sdk/flags/node` | Server-side flags with local evaluation and streaming updates | [docs/flags-node.md](./docs/flags-node.md) |
|
|
231
|
+
| `@shelbyb/daywalker-sdk/flags/react` | React provider and hooks (SSR bootstrap, no flicker) | [docs/flags-react.md](./docs/flags-react.md) |
|
|
232
|
+
| `@shelbyb/daywalker-sdk/flags/engine` | The deterministic evaluation engine (advanced / testing) | — |
|
|
233
|
+
| `@shelbyb/daywalker-sdk/openfeature/server`, `/openfeature/web` | OpenFeature providers | [docs/openfeature.md](./docs/openfeature.md) |
|
|
234
|
+
| `@shelbyb/daywalker-sdk/analytics` | Product analytics: events, page views, identify, sessions (browser and Node) | [docs/analytics.md](./docs/analytics.md) |
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { createClient } from "@shelbyb/daywalker-sdk/flags";
|
|
238
|
+
import { createAnalytics } from "@shelbyb/daywalker-sdk/analytics";
|
|
239
|
+
|
|
240
|
+
const flags = createClient(clientSideId, { kind: "user", key: user.id }, { baseUrl: "https://devpulse.example.com" });
|
|
241
|
+
const analytics = createAnalytics({ writeKey: "dpa_…", host: "https://devpulse.example.com", flags }); // events carry $feature/<key>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
223
246
|
## License
|
|
224
247
|
|
|
225
248
|
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevPulse product analytics. Works in browsers (automatic page views,
|
|
3
|
+
* sessions, persistence, sendBeacon on page hide) and in Node (pass a
|
|
4
|
+
* distinctId per call). Events are batched and retried with backoff.
|
|
5
|
+
*/
|
|
6
|
+
export type Properties = Record<string, unknown>;
|
|
7
|
+
/** Anything with `allFlags()`, e.g. a DevPulse feature-flag client. */
|
|
8
|
+
export type FlagSource = {
|
|
9
|
+
allFlags(): Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
export type AnalyticsOptions = {
|
|
12
|
+
/** The project's public write key (dpa_…). */
|
|
13
|
+
writeKey: string;
|
|
14
|
+
/** Your DevPulse URL, e.g. https://devpulse.example.com */
|
|
15
|
+
host: string;
|
|
16
|
+
/** Capture $pageview on load and on client-side navigation (browsers). Default true. */
|
|
17
|
+
autocapturePageviews?: boolean;
|
|
18
|
+
/** Send when this many events are queued. Default 20 (1 in Node is not needed: use flush()). */
|
|
19
|
+
flushAt?: number;
|
|
20
|
+
/** Send queued events this often, in ms. Default 2000 in browsers, 10000 in Node. */
|
|
21
|
+
flushInterval?: number;
|
|
22
|
+
/** Events kept while offline; the oldest are dropped past this. Default 1000. */
|
|
23
|
+
maxQueueSize?: number;
|
|
24
|
+
/** Where the anonymous id, super properties and session live. Default localStorage in browsers. */
|
|
25
|
+
persistence?: "localStorage" | "memory";
|
|
26
|
+
/** A new session starts after this much inactivity. Default 30. */
|
|
27
|
+
sessionTimeoutMinutes?: number;
|
|
28
|
+
/** Adds `$feature/<key>` properties with each flag's current value to every event. */
|
|
29
|
+
flags?: FlagSource;
|
|
30
|
+
/** Don't send anything when the browser's Do Not Track is on. Default false. */
|
|
31
|
+
respectDoNotTrack?: boolean;
|
|
32
|
+
/** Start opted out (e.g. until the user accepts a consent banner). */
|
|
33
|
+
optedOut?: boolean;
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
onError?: (err: Error) => void;
|
|
36
|
+
};
|
|
37
|
+
export type CaptureOptions = {
|
|
38
|
+
/** Server-side: who did it. Defaults to the current browser user. */
|
|
39
|
+
distinctId?: string;
|
|
40
|
+
timestamp?: Date;
|
|
41
|
+
$set?: Properties;
|
|
42
|
+
$set_once?: Properties;
|
|
43
|
+
};
|
|
44
|
+
export declare const VERSION = "0.1.0";
|
|
45
|
+
/** Time-ordered UUID (v7). */
|
|
46
|
+
export declare function uuidv7(ms?: number): string;
|
|
47
|
+
export declare class Analytics {
|
|
48
|
+
private opts;
|
|
49
|
+
private store;
|
|
50
|
+
private state;
|
|
51
|
+
private queue;
|
|
52
|
+
private timer;
|
|
53
|
+
private sending;
|
|
54
|
+
private backoff;
|
|
55
|
+
private lastUrl;
|
|
56
|
+
private cleanup;
|
|
57
|
+
private endpoint;
|
|
58
|
+
constructor(options: AnalyticsOptions);
|
|
59
|
+
/** Record an event. */
|
|
60
|
+
capture(event: string, properties?: Properties, opts?: CaptureOptions): void;
|
|
61
|
+
/** Record a page view (automatic unless autocapturePageviews is false). */
|
|
62
|
+
pageview(properties?: Properties): void;
|
|
63
|
+
/**
|
|
64
|
+
* Link this browser to a known user. Events from before (the anonymous id)
|
|
65
|
+
* are merged into the user's profile. Optionally set person properties.
|
|
66
|
+
*/
|
|
67
|
+
identify(distinctId: string, set?: Properties, setOnce?: Properties): void;
|
|
68
|
+
/** Update the current person's properties. */
|
|
69
|
+
setPersonProperties(set?: Properties, setOnce?: Properties, opts?: {
|
|
70
|
+
distinctId?: string;
|
|
71
|
+
}): void;
|
|
72
|
+
/** Properties sent with every event from now on (persisted in browsers). */
|
|
73
|
+
register(properties: Properties): void;
|
|
74
|
+
unregister(key: string): void;
|
|
75
|
+
/** On logout: a new anonymous id, no super properties, a new session. */
|
|
76
|
+
reset(): void;
|
|
77
|
+
optOut(): void;
|
|
78
|
+
optIn(): void;
|
|
79
|
+
hasOptedOut(): boolean;
|
|
80
|
+
getDistinctId(): string;
|
|
81
|
+
getSessionId(): string | null;
|
|
82
|
+
/** Send everything queued now. */
|
|
83
|
+
flush(): Promise<void>;
|
|
84
|
+
/** Flush and detach listeners (call before a Node process exits). */
|
|
85
|
+
shutdown(): Promise<void>;
|
|
86
|
+
private disabled;
|
|
87
|
+
private context;
|
|
88
|
+
private flagProps;
|
|
89
|
+
/** Rolling session: renewed after inactivity or at most every 24 hours. */
|
|
90
|
+
private session;
|
|
91
|
+
private enqueue;
|
|
92
|
+
private schedule;
|
|
93
|
+
/** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
|
|
94
|
+
private send;
|
|
95
|
+
/** On page hide, hand the queue to sendBeacon so it survives navigation. */
|
|
96
|
+
private beacon;
|
|
97
|
+
private attachBrowser;
|
|
98
|
+
}
|
|
99
|
+
export declare function createAnalytics(options: AnalyticsOptions): Analytics;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevPulse product analytics. Works in browsers (automatic page views,
|
|
3
|
+
* sessions, persistence, sendBeacon on page hide) and in Node (pass a
|
|
4
|
+
* distinctId per call). Events are batched and retried with backoff.
|
|
5
|
+
*/
|
|
6
|
+
export const VERSION = "0.1.0";
|
|
7
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
8
|
+
const MAX_SESSION_MS = 24 * 3_600_000;
|
|
9
|
+
/** Time-ordered UUID (v7). */
|
|
10
|
+
export function uuidv7(ms = Date.now()) {
|
|
11
|
+
const b = new Uint8Array(16);
|
|
12
|
+
const c = globalThis.crypto;
|
|
13
|
+
if (c?.getRandomValues)
|
|
14
|
+
c.getRandomValues(b);
|
|
15
|
+
else
|
|
16
|
+
for (let i = 0; i < 16; i++)
|
|
17
|
+
b[i] = Math.floor(Math.random() * 256);
|
|
18
|
+
for (let i = 0; i < 6; i++)
|
|
19
|
+
b[i] = Math.floor(ms / 2 ** (8 * (5 - i))) % 256;
|
|
20
|
+
b[6] = (b[6] & 0x0f) | 0x70;
|
|
21
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
22
|
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
24
|
+
}
|
|
25
|
+
class Store {
|
|
26
|
+
key;
|
|
27
|
+
local;
|
|
28
|
+
mem = null;
|
|
29
|
+
constructor(key, local) {
|
|
30
|
+
this.key = key;
|
|
31
|
+
this.local = local;
|
|
32
|
+
}
|
|
33
|
+
load() {
|
|
34
|
+
if (this.mem)
|
|
35
|
+
return this.mem;
|
|
36
|
+
if (this.local) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = localStorage.getItem(this.key);
|
|
39
|
+
if (raw)
|
|
40
|
+
this.mem = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* storage blocked: memory only */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return this.mem;
|
|
47
|
+
}
|
|
48
|
+
save(s) {
|
|
49
|
+
this.mem = s;
|
|
50
|
+
if (!this.local)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(this.key, JSON.stringify(s));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* storage full or blocked */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class Analytics {
|
|
61
|
+
opts;
|
|
62
|
+
store;
|
|
63
|
+
state;
|
|
64
|
+
queue = [];
|
|
65
|
+
timer;
|
|
66
|
+
sending = null;
|
|
67
|
+
backoff = 0;
|
|
68
|
+
lastUrl = "";
|
|
69
|
+
cleanup = [];
|
|
70
|
+
endpoint;
|
|
71
|
+
constructor(options) {
|
|
72
|
+
if (!options.writeKey)
|
|
73
|
+
throw new Error("writeKey is required");
|
|
74
|
+
if (!options.host)
|
|
75
|
+
throw new Error("host is required");
|
|
76
|
+
this.opts = { flushAt: 20, flushInterval: isBrowser ? 2000 : 10000, maxQueueSize: 1000, sessionTimeoutMinutes: 30, ...options };
|
|
77
|
+
this.endpoint = `${options.host.replace(/\/+$/, "")}/a/batch`;
|
|
78
|
+
this.store = new Store(`dp_analytics_${options.writeKey}`, (options.persistence ?? (isBrowser ? "localStorage" : "memory")) === "localStorage" && isBrowser);
|
|
79
|
+
this.state = this.store.load() ?? { distinctId: uuidv7(), identified: false, props: {} };
|
|
80
|
+
if (options.optedOut !== undefined)
|
|
81
|
+
this.state.optedOut = options.optedOut;
|
|
82
|
+
this.store.save(this.state);
|
|
83
|
+
if (isBrowser)
|
|
84
|
+
this.attachBrowser();
|
|
85
|
+
}
|
|
86
|
+
// ---------- Public API ----------
|
|
87
|
+
/** Record an event. */
|
|
88
|
+
capture(event, properties = {}, opts = {}) {
|
|
89
|
+
if (!event || this.disabled())
|
|
90
|
+
return;
|
|
91
|
+
const now = opts.timestamp ?? new Date();
|
|
92
|
+
const props = { ...this.context(), ...this.state.props, ...this.flagProps(), ...properties };
|
|
93
|
+
if (!opts.distinctId)
|
|
94
|
+
props.$session_id = this.session(now.getTime());
|
|
95
|
+
const e = { uuid: uuidv7(now.getTime()), event, distinct_id: opts.distinctId ?? this.state.distinctId, timestamp: now.toISOString(), properties: props };
|
|
96
|
+
if (opts.$set)
|
|
97
|
+
e.$set = opts.$set;
|
|
98
|
+
if (opts.$set_once)
|
|
99
|
+
e.$set_once = opts.$set_once;
|
|
100
|
+
this.enqueue(e);
|
|
101
|
+
}
|
|
102
|
+
/** Record a page view (automatic unless autocapturePageviews is false). */
|
|
103
|
+
pageview(properties = {}) {
|
|
104
|
+
if (!isBrowser)
|
|
105
|
+
return this.capture("$pageview", properties);
|
|
106
|
+
const url = location.href;
|
|
107
|
+
this.lastUrl = url;
|
|
108
|
+
const utm = Object.fromEntries([...new URLSearchParams(location.search)].filter(([k]) => /^utm_(source|medium|campaign|term|content)$/.test(k)));
|
|
109
|
+
let setOnce;
|
|
110
|
+
if (!this.state.initialSent) {
|
|
111
|
+
setOnce = {
|
|
112
|
+
$initial_referrer: document.referrer || "$direct",
|
|
113
|
+
$initial_referring_domain: referringDomain(document.referrer) ?? "$direct",
|
|
114
|
+
$initial_current_url: url,
|
|
115
|
+
$initial_pathname: location.pathname,
|
|
116
|
+
...Object.fromEntries(Object.entries(utm).map(([k, v]) => [`$initial_${k}`, v])),
|
|
117
|
+
};
|
|
118
|
+
this.state.initialSent = true;
|
|
119
|
+
this.store.save(this.state);
|
|
120
|
+
}
|
|
121
|
+
this.capture("$pageview", { $title: document.title, ...utm, ...properties }, { $set_once: setOnce });
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Link this browser to a known user. Events from before (the anonymous id)
|
|
125
|
+
* are merged into the user's profile. Optionally set person properties.
|
|
126
|
+
*/
|
|
127
|
+
identify(distinctId, set, setOnce) {
|
|
128
|
+
if (!distinctId || this.disabled())
|
|
129
|
+
return;
|
|
130
|
+
const previous = this.state.distinctId;
|
|
131
|
+
if (previous === distinctId) {
|
|
132
|
+
if (set || setOnce)
|
|
133
|
+
this.setPersonProperties(set, setOnce);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.state = { ...this.state, distinctId, identified: true };
|
|
137
|
+
this.store.save(this.state);
|
|
138
|
+
this.capture("$identify", { $anon_distinct_id: previous }, { $set: set, $set_once: setOnce });
|
|
139
|
+
}
|
|
140
|
+
/** Update the current person's properties. */
|
|
141
|
+
setPersonProperties(set, setOnce, opts = {}) {
|
|
142
|
+
this.capture("$set", {}, { distinctId: opts.distinctId, $set: set, $set_once: setOnce });
|
|
143
|
+
}
|
|
144
|
+
/** Properties sent with every event from now on (persisted in browsers). */
|
|
145
|
+
register(properties) {
|
|
146
|
+
this.state = { ...this.state, props: { ...this.state.props, ...properties } };
|
|
147
|
+
this.store.save(this.state);
|
|
148
|
+
}
|
|
149
|
+
unregister(key) {
|
|
150
|
+
const props = { ...this.state.props };
|
|
151
|
+
delete props[key];
|
|
152
|
+
this.state = { ...this.state, props };
|
|
153
|
+
this.store.save(this.state);
|
|
154
|
+
}
|
|
155
|
+
/** On logout: a new anonymous id, no super properties, a new session. */
|
|
156
|
+
reset() {
|
|
157
|
+
this.state = { distinctId: uuidv7(), identified: false, props: {}, optedOut: this.state.optedOut, initialSent: this.state.initialSent };
|
|
158
|
+
this.store.save(this.state);
|
|
159
|
+
}
|
|
160
|
+
optOut() {
|
|
161
|
+
this.state = { ...this.state, optedOut: true };
|
|
162
|
+
this.store.save(this.state);
|
|
163
|
+
this.queue = [];
|
|
164
|
+
}
|
|
165
|
+
optIn() {
|
|
166
|
+
this.state = { ...this.state, optedOut: false };
|
|
167
|
+
this.store.save(this.state);
|
|
168
|
+
}
|
|
169
|
+
hasOptedOut() {
|
|
170
|
+
return !!this.state.optedOut;
|
|
171
|
+
}
|
|
172
|
+
getDistinctId() {
|
|
173
|
+
return this.state.distinctId;
|
|
174
|
+
}
|
|
175
|
+
getSessionId() {
|
|
176
|
+
return this.state.session?.id ?? null;
|
|
177
|
+
}
|
|
178
|
+
/** Send everything queued now. */
|
|
179
|
+
async flush() {
|
|
180
|
+
clearTimeout(this.timer);
|
|
181
|
+
this.timer = undefined;
|
|
182
|
+
while (this.queue.length) {
|
|
183
|
+
if (this.sending)
|
|
184
|
+
await this.sending;
|
|
185
|
+
else {
|
|
186
|
+
this.sending = this.send().finally(() => (this.sending = null));
|
|
187
|
+
const ok = await this.sending;
|
|
188
|
+
if (!ok)
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Flush and detach listeners (call before a Node process exits). */
|
|
194
|
+
async shutdown() {
|
|
195
|
+
for (const fn of this.cleanup.splice(0))
|
|
196
|
+
fn();
|
|
197
|
+
await this.flush();
|
|
198
|
+
}
|
|
199
|
+
// ---------- Internals ----------
|
|
200
|
+
disabled() {
|
|
201
|
+
if (this.state.optedOut)
|
|
202
|
+
return true;
|
|
203
|
+
if (this.opts.respectDoNotTrack && isBrowser) {
|
|
204
|
+
const nav = navigator;
|
|
205
|
+
return nav.doNotTrack === "1" || nav.msDoNotTrack === "1" || window.doNotTrack === "1";
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
context() {
|
|
210
|
+
const base = { $lib: "devpulse-analytics", $lib_version: VERSION };
|
|
211
|
+
if (!isBrowser)
|
|
212
|
+
return base;
|
|
213
|
+
return {
|
|
214
|
+
...base,
|
|
215
|
+
$current_url: location.href,
|
|
216
|
+
$host: location.host,
|
|
217
|
+
$pathname: location.pathname,
|
|
218
|
+
$referrer: document.referrer || "$direct",
|
|
219
|
+
$referring_domain: referringDomain(document.referrer) ?? "$direct",
|
|
220
|
+
$screen_width: screen.width,
|
|
221
|
+
$screen_height: screen.height,
|
|
222
|
+
$viewport_width: window.innerWidth,
|
|
223
|
+
$viewport_height: window.innerHeight,
|
|
224
|
+
$browser_language: navigator.language,
|
|
225
|
+
$device_id: this.state.identified ? undefined : this.state.distinctId,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
flagProps() {
|
|
229
|
+
if (!this.opts.flags)
|
|
230
|
+
return {};
|
|
231
|
+
try {
|
|
232
|
+
const all = this.opts.flags.allFlags();
|
|
233
|
+
return Object.fromEntries(Object.entries(all).map(([k, v]) => [`$feature/${k}`, v]));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Rolling session: renewed after inactivity or at most every 24 hours. */
|
|
240
|
+
session(now) {
|
|
241
|
+
const s = this.state.session;
|
|
242
|
+
const timeout = this.opts.sessionTimeoutMinutes * 60_000;
|
|
243
|
+
if (!s || now - s.last > timeout || now - s.start > MAX_SESSION_MS)
|
|
244
|
+
this.state.session = { id: uuidv7(now), last: now, start: now };
|
|
245
|
+
else
|
|
246
|
+
s.last = now;
|
|
247
|
+
this.store.save(this.state);
|
|
248
|
+
return this.state.session.id;
|
|
249
|
+
}
|
|
250
|
+
enqueue(e) {
|
|
251
|
+
this.queue.push(e);
|
|
252
|
+
if (this.queue.length > this.opts.maxQueueSize)
|
|
253
|
+
this.queue.splice(0, this.queue.length - this.opts.maxQueueSize);
|
|
254
|
+
if (this.queue.length >= this.opts.flushAt && !this.backoff)
|
|
255
|
+
void this.flush();
|
|
256
|
+
else
|
|
257
|
+
this.schedule();
|
|
258
|
+
}
|
|
259
|
+
schedule() {
|
|
260
|
+
if (this.timer)
|
|
261
|
+
return;
|
|
262
|
+
this.timer = setTimeout(() => {
|
|
263
|
+
this.timer = undefined;
|
|
264
|
+
void this.flush();
|
|
265
|
+
}, this.backoff || this.opts.flushInterval);
|
|
266
|
+
this.timer.unref?.();
|
|
267
|
+
}
|
|
268
|
+
/** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
|
|
269
|
+
async send() {
|
|
270
|
+
const batch = this.queue.splice(0, 100);
|
|
271
|
+
if (!batch.length)
|
|
272
|
+
return true;
|
|
273
|
+
const f = this.opts.fetch ?? globalThis.fetch;
|
|
274
|
+
try {
|
|
275
|
+
const res = await f(this.endpoint, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { "content-type": "application/json" },
|
|
278
|
+
body: JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: new Date().toISOString() }),
|
|
279
|
+
keepalive: isBrowser,
|
|
280
|
+
});
|
|
281
|
+
if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
|
|
282
|
+
if (!res.ok)
|
|
283
|
+
this.opts.onError?.(new Error(`DevPulse analytics rejected ${batch.length} events: HTTP ${res.status}`));
|
|
284
|
+
this.backoff = 0;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
throw new Error(`HTTP ${res.status}`);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
this.queue.unshift(...batch);
|
|
291
|
+
this.backoff = Math.min(60_000, (this.backoff || 1000) * 2);
|
|
292
|
+
this.opts.onError?.(err);
|
|
293
|
+
this.schedule();
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** On page hide, hand the queue to sendBeacon so it survives navigation. */
|
|
298
|
+
beacon() {
|
|
299
|
+
if (!this.queue.length || typeof navigator.sendBeacon !== "function")
|
|
300
|
+
return;
|
|
301
|
+
const batch = this.queue.splice(0, 100);
|
|
302
|
+
const body = JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: new Date().toISOString() });
|
|
303
|
+
// text/plain avoids a CORS preflight, which beacons can't do.
|
|
304
|
+
if (!navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" })))
|
|
305
|
+
this.queue.unshift(...batch);
|
|
306
|
+
}
|
|
307
|
+
attachBrowser() {
|
|
308
|
+
const onHide = () => document.visibilityState === "hidden" && this.beacon();
|
|
309
|
+
const onPageHide = () => this.beacon();
|
|
310
|
+
document.addEventListener("visibilitychange", onHide);
|
|
311
|
+
window.addEventListener("pagehide", onPageHide);
|
|
312
|
+
this.cleanup.push(() => {
|
|
313
|
+
document.removeEventListener("visibilitychange", onHide);
|
|
314
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
315
|
+
});
|
|
316
|
+
if (this.opts.autocapturePageviews === false)
|
|
317
|
+
return;
|
|
318
|
+
const onNav = () => {
|
|
319
|
+
// Route changes fire before the new title is set; give the app a tick.
|
|
320
|
+
setTimeout(() => location.href !== this.lastUrl && this.pageview(), 0);
|
|
321
|
+
};
|
|
322
|
+
const { pushState, replaceState } = history;
|
|
323
|
+
history.pushState = function (...args) {
|
|
324
|
+
pushState.apply(this, args);
|
|
325
|
+
onNav();
|
|
326
|
+
};
|
|
327
|
+
history.replaceState = function (...args) {
|
|
328
|
+
replaceState.apply(this, args);
|
|
329
|
+
onNav();
|
|
330
|
+
};
|
|
331
|
+
window.addEventListener("popstate", onNav);
|
|
332
|
+
this.cleanup.push(() => {
|
|
333
|
+
history.pushState = pushState;
|
|
334
|
+
history.replaceState = replaceState;
|
|
335
|
+
window.removeEventListener("popstate", onNav);
|
|
336
|
+
});
|
|
337
|
+
this.pageview();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function referringDomain(ref) {
|
|
341
|
+
if (!ref)
|
|
342
|
+
return undefined;
|
|
343
|
+
try {
|
|
344
|
+
return new URL(ref).hostname;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
export function createAnalytics(options) {
|
|
351
|
+
return new Analytics(options);
|
|
352
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ContextInput, EvaluationReason, FlagValue } from "./engine/index.js";
|
|
2
|
+
import { type Logger } from "./common/index.js";
|
|
3
|
+
export type { ContextInput, EvaluationReason, FlagValue, Logger };
|
|
4
|
+
export interface FlagState {
|
|
5
|
+
value: FlagValue;
|
|
6
|
+
variation: number | null;
|
|
7
|
+
version: number;
|
|
8
|
+
reason?: EvaluationReason;
|
|
9
|
+
trackEvents?: boolean;
|
|
10
|
+
trackReason?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type FlagSet = Record<string, FlagState>;
|
|
13
|
+
export interface ClientOptions {
|
|
14
|
+
/** Base URL of your DevPulse deployment, e.g. https://flags.example.com */
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
/** Receive changes in real time over Server-Sent Events (default true). */
|
|
17
|
+
streaming?: boolean;
|
|
18
|
+
/** Initial flag values, e.g. from the Node SDK's allFlagsState() for SSR. */
|
|
19
|
+
bootstrap?: FlagSet | Record<string, FlagValue>;
|
|
20
|
+
/** Secure mode hash for the context (HMAC-SHA256 computed on your server). */
|
|
21
|
+
hash?: string;
|
|
22
|
+
/** Include evaluation reasons (for variationDetail). */
|
|
23
|
+
withReasons?: boolean;
|
|
24
|
+
sendEvents?: boolean;
|
|
25
|
+
flushIntervalMs?: number;
|
|
26
|
+
privateAttributes?: string[];
|
|
27
|
+
allAttributesPrivate?: boolean;
|
|
28
|
+
/** Cache the last flags in localStorage and use them while loading. */
|
|
29
|
+
cache?: boolean;
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
}
|
|
33
|
+
export interface EvaluationDetail<T = FlagValue> {
|
|
34
|
+
value: T;
|
|
35
|
+
variationIndex: number | null;
|
|
36
|
+
reason: EvaluationReason | null;
|
|
37
|
+
}
|
|
38
|
+
type Listener = (...args: unknown[]) => void;
|
|
39
|
+
/**
|
|
40
|
+
* A client for one evaluation context. Fetches evaluated flags, keeps them
|
|
41
|
+
* current over SSE, and reports analytics.
|
|
42
|
+
*
|
|
43
|
+
* const client = createClient("<client-side id>", { key: user.id }, { baseUrl });
|
|
44
|
+
* await client.waitForInitialization();
|
|
45
|
+
* client.on("change:new-checkout", (value) => render(value));
|
|
46
|
+
* client.variation("new-checkout", false);
|
|
47
|
+
*/
|
|
48
|
+
export declare class DevPulseClient {
|
|
49
|
+
private readonly clientSideId;
|
|
50
|
+
private context;
|
|
51
|
+
private readonly options;
|
|
52
|
+
private flags;
|
|
53
|
+
private listeners;
|
|
54
|
+
private stream;
|
|
55
|
+
private events;
|
|
56
|
+
private ready;
|
|
57
|
+
private resolveReady;
|
|
58
|
+
private rejectReady;
|
|
59
|
+
private initialized;
|
|
60
|
+
private closed;
|
|
61
|
+
private readonly logger;
|
|
62
|
+
private readonly base;
|
|
63
|
+
constructor(clientSideId: string, context: ContextInput, options: ClientOptions);
|
|
64
|
+
private query;
|
|
65
|
+
private start;
|
|
66
|
+
private fetchFlags;
|
|
67
|
+
private onStreamEvent;
|
|
68
|
+
private replaceAll;
|
|
69
|
+
private markReady;
|
|
70
|
+
private fail;
|
|
71
|
+
private emitChanges;
|
|
72
|
+
private cacheKey;
|
|
73
|
+
private readCache;
|
|
74
|
+
private writeCache;
|
|
75
|
+
/** Resolves once flags are loaded (or rejects on a fatal error such as an invalid client-side ID). */
|
|
76
|
+
waitForInitialization(opts?: {
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
get isInitialized(): boolean;
|
|
80
|
+
variation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
81
|
+
variationDetail<T extends FlagValue>(key: string, defaultValue: T): EvaluationDetail<T>;
|
|
82
|
+
boolVariation(key: string, defaultValue: boolean): boolean;
|
|
83
|
+
stringVariation(key: string, defaultValue: string): string;
|
|
84
|
+
numberVariation(key: string, defaultValue: number): number;
|
|
85
|
+
jsonVariation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
86
|
+
/** All flag values (doesn't record evaluations). */
|
|
87
|
+
allFlags(): Record<string, FlagValue>;
|
|
88
|
+
/** Switch to a different context (e.g. after login). Resolves with the new flags. */
|
|
89
|
+
identify(context: ContextInput, opts?: {
|
|
90
|
+
hash?: string;
|
|
91
|
+
}): Promise<Record<string, FlagValue>>;
|
|
92
|
+
getContext(): ContextInput;
|
|
93
|
+
/** Record a custom event for metrics and experiments. */
|
|
94
|
+
track(key: string, data?: unknown, metricValue?: number): void;
|
|
95
|
+
on(event: "ready" | "failed" | "change" | `change:${string}`, fn: Listener): () => void;
|
|
96
|
+
off(event: string, fn: Listener): void;
|
|
97
|
+
private emit;
|
|
98
|
+
flush(): Promise<void>;
|
|
99
|
+
close(): Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
export declare function createClient(clientSideId: string, context: ContextInput, options: ClientOptions): DevPulseClient;
|