@shelbyb/daywalker-sdk 0.1.4 → 0.3.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 +31 -2
- package/dist/analytics/index.d.ts +108 -0
- package/dist/analytics/index.js +371 -0
- package/dist/cli.js +5 -0
- package/dist/client.d.ts +15 -1
- package/dist/client.js +59 -12
- package/dist/config.d.ts +6 -0
- package/dist/config.js +22 -0
- package/dist/crypto/signatures.d.ts +18 -5
- package/dist/crypto/signatures.js +100 -41
- package/dist/flags/client.d.ts +102 -0
- package/dist/flags/client.js +302 -0
- package/dist/flags/common/events.d.ts +59 -0
- package/dist/flags/common/events.js +204 -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 +81 -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 +13 -0
- package/dist/flags/engine/operators.js +139 -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 +263 -0
- package/dist/flags/react.d.ts +27 -0
- package/dist/flags/react.js +62 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/next.d.ts +15 -2
- package/dist/next.js +41 -9
- 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/dist/types.d.ts +3 -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 +62 -7
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { EventProcessor, SseClient, encodeContext, noopLogger } from "./common/index.js";
|
|
2
|
+
import { assertSecureUrl } from "../config.js";
|
|
3
|
+
/** A flag map without a prototype, so keys like `__proto__` or `toString` never resolve to inherited values. */
|
|
4
|
+
function flagMap(entries = {}) {
|
|
5
|
+
return Object.assign(Object.create(null), entries);
|
|
6
|
+
}
|
|
7
|
+
function isFlagSet(x) {
|
|
8
|
+
return !!x && typeof x === "object" && Object.values(x).every((v) => v && typeof v === "object" && "value" in v && "version" in v);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A client for one evaluation context. Fetches evaluated flags, keeps them
|
|
12
|
+
* current over SSE, and reports analytics.
|
|
13
|
+
*
|
|
14
|
+
* const client = createClient("<client-side id>", { key: user.id }, { baseUrl });
|
|
15
|
+
* await client.waitForInitialization();
|
|
16
|
+
* client.on("change:new-checkout", (value) => render(value));
|
|
17
|
+
* client.variation("new-checkout", false);
|
|
18
|
+
*/
|
|
19
|
+
export class DevPulseClient {
|
|
20
|
+
clientSideId;
|
|
21
|
+
context;
|
|
22
|
+
options;
|
|
23
|
+
flags = flagMap();
|
|
24
|
+
listeners = new Map();
|
|
25
|
+
stream = null;
|
|
26
|
+
events = null;
|
|
27
|
+
ready;
|
|
28
|
+
resolveReady;
|
|
29
|
+
rejectReady;
|
|
30
|
+
initialized = false;
|
|
31
|
+
closed = false;
|
|
32
|
+
logger;
|
|
33
|
+
base;
|
|
34
|
+
constructor(clientSideId, context, options) {
|
|
35
|
+
this.clientSideId = clientSideId;
|
|
36
|
+
this.context = context;
|
|
37
|
+
this.options = options;
|
|
38
|
+
this.logger = options.logger ?? noopLogger;
|
|
39
|
+
assertSecureUrl("baseUrl", options.baseUrl);
|
|
40
|
+
this.base = options.baseUrl.replace(/\/$/, "");
|
|
41
|
+
this.ready = new Promise((res, rej) => {
|
|
42
|
+
this.resolveReady = res;
|
|
43
|
+
this.rejectReady = rej;
|
|
44
|
+
});
|
|
45
|
+
this.ready.catch(() => undefined);
|
|
46
|
+
if (options.bootstrap) {
|
|
47
|
+
this.flags = isFlagSet(options.bootstrap)
|
|
48
|
+
? flagMap(options.bootstrap)
|
|
49
|
+
: flagMap(Object.fromEntries(Object.entries(options.bootstrap).map(([k, v]) => [k, { value: v, variation: null, version: 0 }])));
|
|
50
|
+
this.markReady();
|
|
51
|
+
}
|
|
52
|
+
else if (options.cache) {
|
|
53
|
+
const cached = this.readCache();
|
|
54
|
+
if (cached)
|
|
55
|
+
this.flags = flagMap(cached);
|
|
56
|
+
}
|
|
57
|
+
if (options.sendEvents !== false && typeof globalThis.fetch === "function") {
|
|
58
|
+
this.events = new EventProcessor({
|
|
59
|
+
url: `${this.base}/sdk/v1/events/client/${encodeURIComponent(clientSideId)}`,
|
|
60
|
+
flushIntervalMs: options.flushIntervalMs ?? 5000,
|
|
61
|
+
privateAttributes: options.privateAttributes,
|
|
62
|
+
allAttributesPrivate: options.allAttributesPrivate,
|
|
63
|
+
fetch: options.fetch,
|
|
64
|
+
beacon: true,
|
|
65
|
+
logger: this.logger,
|
|
66
|
+
});
|
|
67
|
+
this.events.identify(context);
|
|
68
|
+
const doc = globalThis.document;
|
|
69
|
+
doc?.addEventListener("visibilitychange", () => {
|
|
70
|
+
if (doc.visibilityState === "hidden")
|
|
71
|
+
void this.events?.flush();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
void this.start();
|
|
75
|
+
}
|
|
76
|
+
query() {
|
|
77
|
+
const p = new URLSearchParams({ context: encodeContext(this.context) });
|
|
78
|
+
if (this.options.hash)
|
|
79
|
+
p.set("h", this.options.hash);
|
|
80
|
+
if (this.options.withReasons)
|
|
81
|
+
p.set("withReasons", "true");
|
|
82
|
+
return p.toString();
|
|
83
|
+
}
|
|
84
|
+
async start() {
|
|
85
|
+
if (this.options.streaming === false) {
|
|
86
|
+
try {
|
|
87
|
+
this.replaceAll(await this.fetchFlags());
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
this.fail(err);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.stream = new SseClient({
|
|
95
|
+
url: () => `${this.base}/sdk/v1/client/${encodeURIComponent(this.clientSideId)}/stream?${this.query()}`,
|
|
96
|
+
fetch: this.options.fetch,
|
|
97
|
+
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
98
|
+
onOpen: () => this.logger.debug("DevPulse stream connected"),
|
|
99
|
+
onError: (err) => {
|
|
100
|
+
this.logger.warn(`DevPulse stream error: ${err.message}`);
|
|
101
|
+
if (!err.recoverable) {
|
|
102
|
+
this.fail(err);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
// Keep serving flags while reconnecting; fetch once so a first load still succeeds.
|
|
106
|
+
if (!this.initialized)
|
|
107
|
+
this.fetchFlags().then((f) => this.replaceAll(f), () => undefined);
|
|
108
|
+
return true;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
this.stream.start();
|
|
112
|
+
}
|
|
113
|
+
async fetchFlags() {
|
|
114
|
+
const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/client/${encodeURIComponent(this.clientSideId)}/flags?${this.query()}`);
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Error(`Flag request failed with ${res.status}`);
|
|
117
|
+
return (await res.json());
|
|
118
|
+
}
|
|
119
|
+
onStreamEvent(type, data) {
|
|
120
|
+
try {
|
|
121
|
+
const payload = JSON.parse(data);
|
|
122
|
+
if (type === "put")
|
|
123
|
+
this.replaceAll(payload);
|
|
124
|
+
else if (type === "patch") {
|
|
125
|
+
const { key, ...state } = payload;
|
|
126
|
+
const prev = this.flag(key);
|
|
127
|
+
if (prev && prev.version > state.version)
|
|
128
|
+
return;
|
|
129
|
+
this.flags[key] = state;
|
|
130
|
+
this.emitChanges({ [key]: { current: state.value, previous: prev?.value } });
|
|
131
|
+
this.writeCache();
|
|
132
|
+
}
|
|
133
|
+
else if (type === "delete") {
|
|
134
|
+
const { key, version } = payload;
|
|
135
|
+
const prev = this.flag(key);
|
|
136
|
+
if (!prev || prev.version > version)
|
|
137
|
+
return;
|
|
138
|
+
delete this.flags[key];
|
|
139
|
+
this.emitChanges({ [key]: { current: null, previous: prev.value } });
|
|
140
|
+
this.writeCache();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
this.logger.error("Malformed stream event", err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
replaceAll(next) {
|
|
148
|
+
const changes = {};
|
|
149
|
+
for (const key of new Set([...Object.keys(this.flags), ...Object.keys(next)])) {
|
|
150
|
+
const a = this.flag(key)?.value;
|
|
151
|
+
const b = Object.hasOwn(next, key) ? next[key]?.value : undefined;
|
|
152
|
+
if (JSON.stringify(a) !== JSON.stringify(b))
|
|
153
|
+
changes[key] = { current: b ?? null, previous: a };
|
|
154
|
+
}
|
|
155
|
+
this.flags = flagMap(next);
|
|
156
|
+
this.writeCache();
|
|
157
|
+
this.markReady();
|
|
158
|
+
if (Object.keys(changes).length)
|
|
159
|
+
this.emitChanges(changes);
|
|
160
|
+
}
|
|
161
|
+
flag(key) {
|
|
162
|
+
return Object.hasOwn(this.flags, key) ? this.flags[key] : undefined;
|
|
163
|
+
}
|
|
164
|
+
markReady() {
|
|
165
|
+
if (this.initialized)
|
|
166
|
+
return;
|
|
167
|
+
this.initialized = true;
|
|
168
|
+
this.resolveReady();
|
|
169
|
+
this.emit("ready");
|
|
170
|
+
}
|
|
171
|
+
fail(err) {
|
|
172
|
+
if (!this.initialized)
|
|
173
|
+
this.rejectReady(err);
|
|
174
|
+
this.emit("failed", err);
|
|
175
|
+
}
|
|
176
|
+
emitChanges(changes) {
|
|
177
|
+
this.emit("change", changes);
|
|
178
|
+
for (const [key, c] of Object.entries(changes))
|
|
179
|
+
this.emit(`change:${key}`, c.current, c.previous);
|
|
180
|
+
}
|
|
181
|
+
cacheKey() {
|
|
182
|
+
return `devpulse:${this.clientSideId}:${encodeContext(this.context)}`;
|
|
183
|
+
}
|
|
184
|
+
readCache() {
|
|
185
|
+
try {
|
|
186
|
+
const raw = globalThis.localStorage?.getItem(this.cacheKey());
|
|
187
|
+
return raw ? JSON.parse(raw) : null;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
writeCache() {
|
|
194
|
+
if (!this.options.cache)
|
|
195
|
+
return;
|
|
196
|
+
try {
|
|
197
|
+
globalThis.localStorage?.setItem(this.cacheKey(), JSON.stringify(this.flags));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* storage full or unavailable */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// ---------- Public API ----------
|
|
204
|
+
/** Resolves once flags are loaded (or rejects on a fatal error such as an invalid client-side ID). */
|
|
205
|
+
waitForInitialization(opts = {}) {
|
|
206
|
+
if (!opts.timeoutMs)
|
|
207
|
+
return this.ready;
|
|
208
|
+
let timer;
|
|
209
|
+
const timeout = new Promise((_, rej) => {
|
|
210
|
+
timer = setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs);
|
|
211
|
+
});
|
|
212
|
+
return Promise.race([this.ready, timeout]).finally(() => clearTimeout(timer));
|
|
213
|
+
}
|
|
214
|
+
get isInitialized() {
|
|
215
|
+
return this.initialized;
|
|
216
|
+
}
|
|
217
|
+
variation(key, defaultValue) {
|
|
218
|
+
return this.variationDetail(key, defaultValue).value;
|
|
219
|
+
}
|
|
220
|
+
variationDetail(key, defaultValue) {
|
|
221
|
+
const f = this.flag(key);
|
|
222
|
+
let detail;
|
|
223
|
+
if (!f)
|
|
224
|
+
detail = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: this.initialized ? "FLAG_NOT_FOUND" : "CLIENT_NOT_READY" } };
|
|
225
|
+
// A null value means "off with no off variation": serve the caller's default.
|
|
226
|
+
else if (f.value === null)
|
|
227
|
+
detail = { value: defaultValue, variationIndex: f.variation, reason: f.reason ?? null };
|
|
228
|
+
else if (defaultValue !== null && typeof defaultValue !== typeof f.value)
|
|
229
|
+
detail = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "WRONG_TYPE" } };
|
|
230
|
+
else
|
|
231
|
+
detail = { value: f.value, variationIndex: f.variation, reason: f.reason ?? null };
|
|
232
|
+
this.events?.recordEvaluation({ key, context: this.context, value: detail.value, default: defaultValue, variation: detail.variationIndex, version: f?.version, reason: f?.reason, trackEvents: f?.trackEvents });
|
|
233
|
+
return detail;
|
|
234
|
+
}
|
|
235
|
+
boolVariation(key, defaultValue) {
|
|
236
|
+
return this.variation(key, defaultValue);
|
|
237
|
+
}
|
|
238
|
+
stringVariation(key, defaultValue) {
|
|
239
|
+
return this.variation(key, defaultValue);
|
|
240
|
+
}
|
|
241
|
+
numberVariation(key, defaultValue) {
|
|
242
|
+
return this.variation(key, defaultValue);
|
|
243
|
+
}
|
|
244
|
+
jsonVariation(key, defaultValue) {
|
|
245
|
+
return this.variation(key, defaultValue);
|
|
246
|
+
}
|
|
247
|
+
/** All flag values (doesn't record evaluations). */
|
|
248
|
+
allFlags() {
|
|
249
|
+
return Object.fromEntries(Object.entries(this.flags).map(([k, v]) => [k, v.value]));
|
|
250
|
+
}
|
|
251
|
+
/** Switch to a different context (e.g. after login). Resolves with the new flags. */
|
|
252
|
+
async identify(context, opts = {}) {
|
|
253
|
+
this.context = context;
|
|
254
|
+
if (opts.hash !== undefined)
|
|
255
|
+
this.options.hash = opts.hash;
|
|
256
|
+
this.events?.identify(context);
|
|
257
|
+
this.replaceAll(await this.fetchFlags());
|
|
258
|
+
this.stream?.restart();
|
|
259
|
+
return this.allFlags();
|
|
260
|
+
}
|
|
261
|
+
getContext() {
|
|
262
|
+
return this.context;
|
|
263
|
+
}
|
|
264
|
+
/** Record a custom event for metrics and experiments. */
|
|
265
|
+
track(key, data, metricValue) {
|
|
266
|
+
this.events?.track(key, this.context, data, metricValue);
|
|
267
|
+
}
|
|
268
|
+
on(event, fn) {
|
|
269
|
+
let set = this.listeners.get(event);
|
|
270
|
+
if (!set)
|
|
271
|
+
this.listeners.set(event, (set = new Set()));
|
|
272
|
+
set.add(fn);
|
|
273
|
+
return () => this.off(event, fn);
|
|
274
|
+
}
|
|
275
|
+
off(event, fn) {
|
|
276
|
+
this.listeners.get(event)?.delete(fn);
|
|
277
|
+
}
|
|
278
|
+
emit(event, ...args) {
|
|
279
|
+
for (const fn of this.listeners.get(event) ?? []) {
|
|
280
|
+
try {
|
|
281
|
+
fn(...args);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
this.logger.error(`Listener for "${event}" threw`, err);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async flush() {
|
|
289
|
+
await this.events?.flush();
|
|
290
|
+
}
|
|
291
|
+
async close() {
|
|
292
|
+
if (this.closed)
|
|
293
|
+
return;
|
|
294
|
+
this.closed = true;
|
|
295
|
+
this.stream?.stop();
|
|
296
|
+
await this.events?.close();
|
|
297
|
+
this.listeners.clear();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export function createClient(clientSideId, context, options) {
|
|
301
|
+
return new DevPulseClient(clientSideId, context, options);
|
|
302
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type ContextInput, type EvaluationReason, type FlagValue } from "../engine/index.js";
|
|
2
|
+
export interface EventProcessorOptions {
|
|
3
|
+
url: string;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
fetch?: typeof fetch;
|
|
6
|
+
flushIntervalMs?: number;
|
|
7
|
+
capacity?: number;
|
|
8
|
+
/** Treat every attribute (except key/kind) as private. */
|
|
9
|
+
allAttributesPrivate?: boolean;
|
|
10
|
+
/** Attribute names (or RFC 6901 pointers) never sent to DevPulse. */
|
|
11
|
+
privateAttributes?: string[];
|
|
12
|
+
/** Use navigator.sendBeacon when available (browsers, on page hide). */
|
|
13
|
+
beacon?: boolean;
|
|
14
|
+
logger?: Logger;
|
|
15
|
+
}
|
|
16
|
+
export interface Logger {
|
|
17
|
+
debug(...args: unknown[]): void;
|
|
18
|
+
info(...args: unknown[]): void;
|
|
19
|
+
warn(...args: unknown[]): void;
|
|
20
|
+
error(...args: unknown[]): void;
|
|
21
|
+
}
|
|
22
|
+
export declare const noopLogger: Logger;
|
|
23
|
+
/**
|
|
24
|
+
* Buffers analytics: per-flag summaries (counts per variation), context
|
|
25
|
+
* index events (deduplicated), full feature events for tracked flags and
|
|
26
|
+
* experiments, and custom events. Flushes on an interval and on demand.
|
|
27
|
+
*/
|
|
28
|
+
export declare class EventProcessor {
|
|
29
|
+
private readonly opts;
|
|
30
|
+
private summary;
|
|
31
|
+
private summaryStart;
|
|
32
|
+
private queue;
|
|
33
|
+
private seenContexts;
|
|
34
|
+
private timer;
|
|
35
|
+
private disabled;
|
|
36
|
+
private readonly logger;
|
|
37
|
+
constructor(opts: EventProcessorOptions);
|
|
38
|
+
/** Strip private attributes and record which were removed. */
|
|
39
|
+
redact(context: ContextInput): Record<string, unknown>;
|
|
40
|
+
private contextKeys;
|
|
41
|
+
/** Queue an index event the first time a context is seen in a while. */
|
|
42
|
+
private noticeContext;
|
|
43
|
+
recordEvaluation(e: {
|
|
44
|
+
key: string;
|
|
45
|
+
context: ContextInput;
|
|
46
|
+
value: FlagValue;
|
|
47
|
+
default: FlagValue;
|
|
48
|
+
variation: number | null;
|
|
49
|
+
version?: number;
|
|
50
|
+
reason?: EvaluationReason;
|
|
51
|
+
trackEvents?: boolean;
|
|
52
|
+
}): void;
|
|
53
|
+
track(key: string, context: ContextInput, data?: unknown, metricValue?: number): void;
|
|
54
|
+
identify(context: ContextInput): void;
|
|
55
|
+
private push;
|
|
56
|
+
private drain;
|
|
57
|
+
flush(): Promise<void>;
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { parseContext } from "../engine/index.js";
|
|
2
|
+
export const noopLogger = { debug() { }, info() { }, warn() { }, error() { } };
|
|
3
|
+
/** Delete the value at `tokens` inside `target`, copying (not mutating) nested objects. */
|
|
4
|
+
function removePointer(target, tokens) {
|
|
5
|
+
const [head, ...rest] = tokens;
|
|
6
|
+
if (!Object.hasOwn(target, head))
|
|
7
|
+
return false;
|
|
8
|
+
if (!rest.length) {
|
|
9
|
+
delete target[head];
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
const child = target[head];
|
|
13
|
+
if (!child || typeof child !== "object" || Array.isArray(child))
|
|
14
|
+
return false;
|
|
15
|
+
const copy = { ...child };
|
|
16
|
+
if (!removePointer(copy, rest))
|
|
17
|
+
return false;
|
|
18
|
+
target[head] = copy;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Buffers analytics: per-flag summaries (counts per variation), context
|
|
23
|
+
* index events (deduplicated), full feature events for tracked flags and
|
|
24
|
+
* experiments, and custom events. Flushes on an interval and on demand.
|
|
25
|
+
*/
|
|
26
|
+
export class EventProcessor {
|
|
27
|
+
opts;
|
|
28
|
+
summary = new Map();
|
|
29
|
+
summaryStart = 0;
|
|
30
|
+
queue = [];
|
|
31
|
+
seenContexts = new Map();
|
|
32
|
+
timer = null;
|
|
33
|
+
disabled = false;
|
|
34
|
+
logger;
|
|
35
|
+
constructor(opts) {
|
|
36
|
+
this.opts = opts;
|
|
37
|
+
this.logger = opts.logger ?? noopLogger;
|
|
38
|
+
this.timer = setInterval(() => void this.flush(), opts.flushIntervalMs ?? 5000);
|
|
39
|
+
this.timer.unref?.();
|
|
40
|
+
}
|
|
41
|
+
/** Strip private attributes and record which were removed. */
|
|
42
|
+
redact(context) {
|
|
43
|
+
const strip = (c) => {
|
|
44
|
+
const meta = (c._meta ?? {});
|
|
45
|
+
const priv = new Set([...(this.opts.privateAttributes ?? []), ...(meta.privateAttributes ?? [])]);
|
|
46
|
+
const out = {};
|
|
47
|
+
const redacted = [];
|
|
48
|
+
for (const [k, v] of Object.entries(c)) {
|
|
49
|
+
if (k === "_meta")
|
|
50
|
+
continue;
|
|
51
|
+
if (k !== "key" && k !== "kind" && k !== "anonymous" && (this.opts.allAttributesPrivate || priv.has(k) || priv.has(`/${k}`)))
|
|
52
|
+
redacted.push(k);
|
|
53
|
+
else
|
|
54
|
+
out[k] = v;
|
|
55
|
+
}
|
|
56
|
+
// Nested JSON pointers (/address/zip): remove the leaf from a copy of each object on the path.
|
|
57
|
+
for (const ref of priv) {
|
|
58
|
+
const tokens = ref.startsWith("/") ? ref.slice(1).split("/").map((t) => t.replace(/~1/g, "/").replace(/~0/g, "~")) : [];
|
|
59
|
+
if (tokens.length < 2 || ["key", "kind", "anonymous", "_meta"].includes(tokens[0]))
|
|
60
|
+
continue;
|
|
61
|
+
if (removePointer(out, tokens))
|
|
62
|
+
redacted.push(ref);
|
|
63
|
+
}
|
|
64
|
+
if (redacted.length)
|
|
65
|
+
out.redactedAttributes = redacted;
|
|
66
|
+
return out;
|
|
67
|
+
};
|
|
68
|
+
if (context.kind === "multi") {
|
|
69
|
+
const out = { kind: "multi" };
|
|
70
|
+
for (const [k, v] of Object.entries(context))
|
|
71
|
+
if (k !== "kind")
|
|
72
|
+
out[k] = strip(v);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return strip(context);
|
|
76
|
+
}
|
|
77
|
+
contextKeys(context) {
|
|
78
|
+
try {
|
|
79
|
+
const ctx = parseContext(context);
|
|
80
|
+
return Object.fromEntries([...ctx.kinds.values()].map((c) => [c.kind, c.key]));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Queue an index event the first time a context is seen in a while. */
|
|
87
|
+
noticeContext(context) {
|
|
88
|
+
let canonical;
|
|
89
|
+
try {
|
|
90
|
+
canonical = parseContext(context).canonicalKey;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const last = this.seenContexts.get(canonical);
|
|
97
|
+
if (last && now - last < 5 * 60_000)
|
|
98
|
+
return;
|
|
99
|
+
if (this.seenContexts.size > 1000)
|
|
100
|
+
this.seenContexts.clear();
|
|
101
|
+
this.seenContexts.set(canonical, now);
|
|
102
|
+
this.push({ kind: "index", creationDate: now, context: this.redact(context) });
|
|
103
|
+
}
|
|
104
|
+
recordEvaluation(e) {
|
|
105
|
+
if (this.disabled)
|
|
106
|
+
return;
|
|
107
|
+
this.noticeContext(e.context);
|
|
108
|
+
if (!this.summaryStart)
|
|
109
|
+
this.summaryStart = Date.now();
|
|
110
|
+
let s = this.summary.get(e.key);
|
|
111
|
+
if (!s)
|
|
112
|
+
this.summary.set(e.key, (s = { default: e.default, counters: new Map() }));
|
|
113
|
+
const unknown = e.version === undefined;
|
|
114
|
+
const ck = `${e.variation ?? "default"}:${e.version ?? "unknown"}`;
|
|
115
|
+
const c = s.counters.get(ck) ?? { value: e.value, count: 0, ...(unknown ? { unknown: true } : { variation: e.variation ?? undefined, version: e.version }) };
|
|
116
|
+
c.count++;
|
|
117
|
+
s.counters.set(ck, c);
|
|
118
|
+
const inExperiment = !!e.reason && "inExperiment" in e.reason && e.reason.inExperiment;
|
|
119
|
+
if (e.trackEvents || inExperiment) {
|
|
120
|
+
this.push({ kind: "feature", creationDate: Date.now(), key: e.key, version: e.version, variation: e.variation, value: e.value, default: e.default, contextKeys: this.contextKeys(e.context), ...(e.reason ? { reason: e.reason } : {}) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
track(key, context, data, metricValue) {
|
|
124
|
+
if (this.disabled)
|
|
125
|
+
return;
|
|
126
|
+
this.noticeContext(context);
|
|
127
|
+
const keys = this.contextKeys(context);
|
|
128
|
+
if (!keys)
|
|
129
|
+
return this.logger.warn("track() called with an invalid context");
|
|
130
|
+
this.push({ kind: "custom", key, creationDate: Date.now(), contextKeys: keys, ...(data !== undefined ? { data } : {}), ...(typeof metricValue === "number" ? { metricValue } : {}) });
|
|
131
|
+
}
|
|
132
|
+
identify(context) {
|
|
133
|
+
if (this.disabled)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
parseContext(context);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.push({ kind: "identify", creationDate: Date.now(), context: this.redact(context) });
|
|
142
|
+
}
|
|
143
|
+
push(e) {
|
|
144
|
+
if (this.queue.length >= (this.opts.capacity ?? 10_000))
|
|
145
|
+
return this.logger.warn("Event buffer full; dropping events");
|
|
146
|
+
this.queue.push(e);
|
|
147
|
+
}
|
|
148
|
+
drain() {
|
|
149
|
+
const events = this.queue;
|
|
150
|
+
this.queue = [];
|
|
151
|
+
if (this.summary.size) {
|
|
152
|
+
events.push({
|
|
153
|
+
kind: "summary",
|
|
154
|
+
startDate: this.summaryStart,
|
|
155
|
+
endDate: Date.now(),
|
|
156
|
+
features: Object.fromEntries([...this.summary.entries()].map(([k, s]) => [k, { default: s.default, counters: [...s.counters.values()] }])),
|
|
157
|
+
});
|
|
158
|
+
this.summary.clear();
|
|
159
|
+
this.summaryStart = 0;
|
|
160
|
+
}
|
|
161
|
+
return events;
|
|
162
|
+
}
|
|
163
|
+
async flush() {
|
|
164
|
+
if (this.disabled)
|
|
165
|
+
return;
|
|
166
|
+
const events = this.drain();
|
|
167
|
+
if (!events.length)
|
|
168
|
+
return;
|
|
169
|
+
const body = JSON.stringify(events);
|
|
170
|
+
const nav = globalThis.navigator;
|
|
171
|
+
if (this.opts.beacon && nav?.sendBeacon && document?.visibilityState === "hidden") {
|
|
172
|
+
if (nav.sendBeacon(this.opts.url, body))
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
const res = await (this.opts.fetch ?? fetch)(this.opts.url, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "content-type": "application/json", ...this.opts.headers },
|
|
180
|
+
body,
|
|
181
|
+
keepalive: body.length < 60_000,
|
|
182
|
+
});
|
|
183
|
+
if (res.ok)
|
|
184
|
+
return;
|
|
185
|
+
if (res.status === 401 || res.status === 403) {
|
|
186
|
+
this.disabled = true;
|
|
187
|
+
return this.logger.error(`Events rejected (${res.status}); analytics disabled. Check your SDK key.`);
|
|
188
|
+
}
|
|
189
|
+
if (res.status < 500 && res.status !== 429)
|
|
190
|
+
return this.logger.warn(`Events rejected (${res.status})`);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (attempt === 1)
|
|
194
|
+
this.logger.warn("Failed to send events", err);
|
|
195
|
+
}
|
|
196
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async close() {
|
|
200
|
+
if (this.timer)
|
|
201
|
+
clearInterval(this.timer);
|
|
202
|
+
await this.flush();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SseParser, SseClient, SseError, backoffDelay, type SseEvent, type SseClientOptions } from "./sse.js";
|
|
2
|
+
export { EventProcessor, noopLogger, type EventProcessorOptions, type Logger } from "./events.js";
|
|
3
|
+
/** Encode a context for URLs (base64url JSON), matching the DevPulse SDK endpoints. */
|
|
4
|
+
export declare function encodeContext(context: unknown): string;
|
|
5
|
+
export declare const SDK_VERSION = "0.1.0";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { SseParser, SseClient, SseError, backoffDelay } from "./sse.js";
|
|
2
|
+
export { EventProcessor, noopLogger } from "./events.js";
|
|
3
|
+
/** Encode a context for URLs (base64url JSON), matching the DevPulse SDK endpoints. */
|
|
4
|
+
export function encodeContext(context) {
|
|
5
|
+
const json = JSON.stringify(context);
|
|
6
|
+
const bytes = new TextEncoder().encode(json);
|
|
7
|
+
let bin = "";
|
|
8
|
+
for (const b of bytes)
|
|
9
|
+
bin += String.fromCharCode(b);
|
|
10
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
|
+
}
|
|
12
|
+
export const SDK_VERSION = "0.1.0";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events over fetch, implementing the WHATWG HTML event-stream
|
|
3
|
+
* interpretation (§9.2.6): CRLF/LF/CR line endings, comments, `event`, `data`,
|
|
4
|
+
* `id` and `retry` fields. Unlike the browser EventSource it supports custom
|
|
5
|
+
* headers and request bodies and runs in Node, browsers, workers and React Native.
|
|
6
|
+
*/
|
|
7
|
+
export interface SseEvent {
|
|
8
|
+
type: string;
|
|
9
|
+
data: string;
|
|
10
|
+
lastEventId: string;
|
|
11
|
+
}
|
|
12
|
+
/** Incremental parser: feed it chunks of text, get dispatched events back. */
|
|
13
|
+
export declare class SseParser {
|
|
14
|
+
private buffer;
|
|
15
|
+
private data;
|
|
16
|
+
private eventType;
|
|
17
|
+
private sawFirstChunk;
|
|
18
|
+
/** The previous chunk ended in CR, so an LF starting this chunk completes a CRLF. */
|
|
19
|
+
private pendingCR;
|
|
20
|
+
lastEventId: string;
|
|
21
|
+
retry: number | null;
|
|
22
|
+
feed(chunk: string): SseEvent[];
|
|
23
|
+
private line;
|
|
24
|
+
}
|
|
25
|
+
export interface SseClientOptions {
|
|
26
|
+
url: string | (() => string);
|
|
27
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
/** Called for every dispatched event. */
|
|
30
|
+
onEvent: (event: SseEvent) => void;
|
|
31
|
+
onOpen?: () => void;
|
|
32
|
+
/** Called on each failure; return false to stop reconnecting (e.g. 401). */
|
|
33
|
+
onError?: (error: SseError) => boolean | void;
|
|
34
|
+
initialRetryMs?: number;
|
|
35
|
+
maxRetryMs?: number;
|
|
36
|
+
/** Reconnect if nothing (not even a heartbeat) arrives for this long. */
|
|
37
|
+
readTimeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare class SseError extends Error {
|
|
40
|
+
readonly status?: number | undefined;
|
|
41
|
+
constructor(message: string, status?: number | undefined);
|
|
42
|
+
/** 4xx (except 408/429) won't succeed on retry. */
|
|
43
|
+
get recoverable(): boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Exponential backoff with full jitter, reset after a stable connection. */
|
|
46
|
+
export declare function backoffDelay(attempt: number, base: number, max: number): number;
|
|
47
|
+
export declare class SseClient {
|
|
48
|
+
private readonly opts;
|
|
49
|
+
private controller;
|
|
50
|
+
private stopped;
|
|
51
|
+
private attempt;
|
|
52
|
+
private parser;
|
|
53
|
+
private timer;
|
|
54
|
+
private watchdog;
|
|
55
|
+
private reader;
|
|
56
|
+
constructor(opts: SseClientOptions);
|
|
57
|
+
start(): void;
|
|
58
|
+
stop(): void;
|
|
59
|
+
/** Drop the current connection and reconnect immediately (e.g. after identify). */
|
|
60
|
+
restart(): void;
|
|
61
|
+
private connect;
|
|
62
|
+
}
|