@shelbyb/daywalker-sdk 0.2.0 → 0.4.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 +38 -24
- package/dist/analytics/index.d.ts +26 -3
- package/dist/analytics/index.js +107 -23
- package/dist/cli.js +136 -29
- package/dist/client.d.ts +44 -3
- package/dist/client.js +372 -73
- package/dist/config.d.ts +14 -2
- package/dist/config.js +36 -0
- package/dist/crypto/encoding.d.ts +5 -0
- package/dist/crypto/encoding.js +14 -0
- package/dist/crypto/index.d.ts +3 -3
- package/dist/crypto/index.js +2 -2
- package/dist/crypto/keys.d.ts +3 -3
- package/dist/crypto/keys.js +28 -7
- package/dist/crypto/signatures.d.ts +54 -11
- package/dist/crypto/signatures.js +310 -94
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +34 -5
- package/dist/flags/client.js +93 -22
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +77 -6
- package/dist/flags/common/index.d.ts +3 -3
- package/dist/flags/common/index.js +3 -3
- package/dist/flags/common/sse.d.ts +24 -3
- package/dist/flags/common/sse.js +85 -15
- package/dist/flags/common/version.d.ts +2 -0
- package/dist/flags/common/version.js +2 -0
- package/dist/flags/engine/context.js +4 -3
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +25 -0
- package/dist/flags/engine/operators.js +64 -8
- package/dist/flags/node.d.ts +27 -15
- package/dist/flags/node.js +54 -7
- package/dist/flags/react.d.ts +6 -4
- package/dist/flags/react.js +47 -17
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +28 -7
- package/dist/next.d.ts +63 -6
- package/dist/next.js +182 -36
- package/dist/openfeature/server.d.ts +3 -0
- package/dist/openfeature/server.js +14 -2
- package/dist/openfeature/shared.d.ts +1 -1
- package/dist/openfeature/shared.js +22 -2
- package/dist/openfeature/web.js +9 -3
- package/dist/types.d.ts +58 -3
- package/dist/types.js +6 -0
- package/docs/analytics.md +1 -1
- package/docs/flags-node.md +1 -1
- package/docs/flags.md +2 -1
- package/package.json +34 -16
- package/assets/logo-512.png +0 -0
package/dist/flags/client.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ContextInput, EvaluationReason, FlagValue } from "./engine/index.js";
|
|
2
|
-
import { type Logger } from "./common/index.js";
|
|
2
|
+
import { SDK_VERSION, type Logger } from "./common/index.js";
|
|
3
3
|
export type { ContextInput, EvaluationReason, FlagValue, Logger };
|
|
4
|
+
export { SDK_VERSION };
|
|
4
5
|
export interface FlagState {
|
|
5
6
|
value: FlagValue;
|
|
6
7
|
variation: number | null;
|
|
@@ -11,11 +12,17 @@ export interface FlagState {
|
|
|
11
12
|
}
|
|
12
13
|
export type FlagSet = Record<string, FlagState>;
|
|
13
14
|
export interface ClientOptions {
|
|
14
|
-
/** Base URL of your DevPulse deployment, e.g. https://flags.example.com */
|
|
15
|
+
/** Base URL of your DevPulse deployment, e.g. https://flags.example.com (https required except on localhost). */
|
|
15
16
|
baseUrl: string;
|
|
16
17
|
/** Receive changes in real time over Server-Sent Events (default true). */
|
|
17
18
|
streaming?: boolean;
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Never touch the network: serve `bootstrap` (or defaults), send no events, open no stream.
|
|
21
|
+
* identify() only switches the context. Used for server-side rendering; handy in tests.
|
|
22
|
+
* The client is ready immediately when `bootstrap` is given.
|
|
23
|
+
*/
|
|
24
|
+
offline?: boolean;
|
|
25
|
+
/** Initial flag values, e.g. from the Node SDK's allFlagsState().toJSON() for SSR. */
|
|
19
26
|
bootstrap?: FlagSet | Record<string, FlagValue>;
|
|
20
27
|
/** Secure mode hash for the context (HMAC-SHA256 computed on your server). */
|
|
21
28
|
hash?: string;
|
|
@@ -58,15 +65,24 @@ export declare class DevPulseClient {
|
|
|
58
65
|
private rejectReady;
|
|
59
66
|
private initialized;
|
|
60
67
|
private closed;
|
|
68
|
+
/** Bumped by identify(); responses for an older context are ignored. */
|
|
69
|
+
private contextSeq;
|
|
70
|
+
private stale;
|
|
71
|
+
private detachVisibility;
|
|
61
72
|
private readonly logger;
|
|
62
73
|
private readonly base;
|
|
63
74
|
constructor(clientSideId: string, context: ContextInput, options: ClientOptions);
|
|
64
75
|
private query;
|
|
65
76
|
private start;
|
|
77
|
+
/** Fetch flags for the current context and apply them unless identify() moved on (or close()) meanwhile. */
|
|
78
|
+
private loadFlags;
|
|
66
79
|
private fetchFlags;
|
|
67
80
|
private onStreamEvent;
|
|
68
81
|
private replaceAll;
|
|
82
|
+
private flag;
|
|
69
83
|
private markReady;
|
|
84
|
+
/** "stale" when the stream drops after loading (flags keep serving), "recovered" once it reconnects. */
|
|
85
|
+
private setStale;
|
|
70
86
|
private fail;
|
|
71
87
|
private emitChanges;
|
|
72
88
|
private cacheKey;
|
|
@@ -77,22 +93,35 @@ export declare class DevPulseClient {
|
|
|
77
93
|
timeoutMs?: number;
|
|
78
94
|
}): Promise<void>;
|
|
79
95
|
get isInitialized(): boolean;
|
|
96
|
+
/** True after close(); a closed client never updates again. */
|
|
97
|
+
get isClosed(): boolean;
|
|
80
98
|
variation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
81
99
|
variationDetail<T extends FlagValue>(key: string, defaultValue: T): EvaluationDetail<T>;
|
|
100
|
+
/** @internal Evaluate without recording an analytics event (React hooks record from an effect). */
|
|
101
|
+
peekVariationDetail<T extends FlagValue>(key: string, defaultValue: T): EvaluationDetail<T>;
|
|
102
|
+
/** @internal Record the analytics event for an evaluation made with peekVariationDetail(). */
|
|
103
|
+
recordEvaluation<T extends FlagValue>(key: string, defaultValue: T, detail: EvaluationDetail<T>): void;
|
|
82
104
|
boolVariation(key: string, defaultValue: boolean): boolean;
|
|
83
105
|
stringVariation(key: string, defaultValue: string): string;
|
|
84
106
|
numberVariation(key: string, defaultValue: number): number;
|
|
85
107
|
jsonVariation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
86
108
|
/** All flag values (doesn't record evaluations). */
|
|
87
109
|
allFlags(): Record<string, FlagValue>;
|
|
88
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Switch to a different context (e.g. after login). Resolves with the new flags. When calls
|
|
112
|
+
* overlap, the last one wins: an earlier call's late response is ignored.
|
|
113
|
+
*/
|
|
89
114
|
identify(context: ContextInput, opts?: {
|
|
90
115
|
hash?: string;
|
|
91
116
|
}): Promise<Record<string, FlagValue>>;
|
|
92
117
|
getContext(): ContextInput;
|
|
93
118
|
/** Record a custom event for metrics and experiments. */
|
|
94
119
|
track(key: string, data?: unknown, metricValue?: number): void;
|
|
95
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Events: "ready", "failed" (fatal, e.g. an invalid client-side ID), "change" / "change:<key>",
|
|
122
|
+
* "stale" (stream lost; the last flags keep serving) and "recovered".
|
|
123
|
+
*/
|
|
124
|
+
on(event: "ready" | "failed" | "change" | "stale" | "recovered" | `change:${string}`, fn: Listener): () => void;
|
|
96
125
|
off(event: string, fn: Listener): void;
|
|
97
126
|
private emit;
|
|
98
127
|
flush(): Promise<void>;
|
package/dist/flags/client.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { EventProcessor, SseClient, encodeContext, noopLogger } from "./common/index.js";
|
|
1
|
+
import { EventProcessor, SDK_VERSION, SseClient, encodeContext, noopLogger } from "./common/index.js";
|
|
2
|
+
import { assertSecureUrl } from "../config.js";
|
|
3
|
+
export { SDK_VERSION };
|
|
4
|
+
/** A flag map without a prototype, so keys like `__proto__` or `toString` never resolve to inherited values. */
|
|
5
|
+
function flagMap(entries = {}) {
|
|
6
|
+
return Object.assign(Object.create(null), entries);
|
|
7
|
+
}
|
|
2
8
|
function isFlagSet(x) {
|
|
3
9
|
return !!x && typeof x === "object" && Object.values(x).every((v) => v && typeof v === "object" && "value" in v && "version" in v);
|
|
4
10
|
}
|
|
@@ -15,7 +21,7 @@ export class DevPulseClient {
|
|
|
15
21
|
clientSideId;
|
|
16
22
|
context;
|
|
17
23
|
options;
|
|
18
|
-
flags =
|
|
24
|
+
flags = flagMap();
|
|
19
25
|
listeners = new Map();
|
|
20
26
|
stream = null;
|
|
21
27
|
events = null;
|
|
@@ -24,6 +30,10 @@ export class DevPulseClient {
|
|
|
24
30
|
rejectReady;
|
|
25
31
|
initialized = false;
|
|
26
32
|
closed = false;
|
|
33
|
+
/** Bumped by identify(); responses for an older context are ignored. */
|
|
34
|
+
contextSeq = 0;
|
|
35
|
+
stale = false;
|
|
36
|
+
detachVisibility = null;
|
|
27
37
|
logger;
|
|
28
38
|
base;
|
|
29
39
|
constructor(clientSideId, context, options) {
|
|
@@ -31,6 +41,7 @@ export class DevPulseClient {
|
|
|
31
41
|
this.context = context;
|
|
32
42
|
this.options = options;
|
|
33
43
|
this.logger = options.logger ?? noopLogger;
|
|
44
|
+
assertSecureUrl("baseUrl", options.baseUrl);
|
|
34
45
|
this.base = options.baseUrl.replace(/\/$/, "");
|
|
35
46
|
this.ready = new Promise((res, rej) => {
|
|
36
47
|
this.resolveReady = res;
|
|
@@ -39,15 +50,17 @@ export class DevPulseClient {
|
|
|
39
50
|
this.ready.catch(() => undefined);
|
|
40
51
|
if (options.bootstrap) {
|
|
41
52
|
this.flags = isFlagSet(options.bootstrap)
|
|
42
|
-
?
|
|
43
|
-
: Object.fromEntries(Object.entries(options.bootstrap).map(([k, v]) => [k, { value: v, variation: null, version: 0 }]));
|
|
53
|
+
? flagMap(options.bootstrap)
|
|
54
|
+
: flagMap(Object.fromEntries(Object.entries(options.bootstrap).map(([k, v]) => [k, { value: v, variation: null, version: 0 }])));
|
|
44
55
|
this.markReady();
|
|
45
56
|
}
|
|
46
57
|
else if (options.cache) {
|
|
47
58
|
const cached = this.readCache();
|
|
48
59
|
if (cached)
|
|
49
|
-
this.flags = cached;
|
|
60
|
+
this.flags = flagMap(cached);
|
|
50
61
|
}
|
|
62
|
+
if (options.offline)
|
|
63
|
+
return;
|
|
51
64
|
if (options.sendEvents !== false && typeof globalThis.fetch === "function") {
|
|
52
65
|
this.events = new EventProcessor({
|
|
53
66
|
url: `${this.base}/sdk/v1/events/client/${encodeURIComponent(clientSideId)}`,
|
|
@@ -60,10 +73,14 @@ export class DevPulseClient {
|
|
|
60
73
|
});
|
|
61
74
|
this.events.identify(context);
|
|
62
75
|
const doc = globalThis.document;
|
|
63
|
-
doc?.addEventListener
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
if (doc?.addEventListener) {
|
|
77
|
+
const onVisibility = () => {
|
|
78
|
+
if (doc.visibilityState === "hidden")
|
|
79
|
+
void this.events?.flush();
|
|
80
|
+
};
|
|
81
|
+
doc.addEventListener("visibilitychange", onVisibility);
|
|
82
|
+
this.detachVisibility = () => doc.removeEventListener("visibilitychange", onVisibility);
|
|
83
|
+
}
|
|
67
84
|
}
|
|
68
85
|
void this.start();
|
|
69
86
|
}
|
|
@@ -78,10 +95,11 @@ export class DevPulseClient {
|
|
|
78
95
|
async start() {
|
|
79
96
|
if (this.options.streaming === false) {
|
|
80
97
|
try {
|
|
81
|
-
|
|
98
|
+
await this.loadFlags();
|
|
82
99
|
}
|
|
83
100
|
catch (err) {
|
|
84
|
-
this.
|
|
101
|
+
if (!this.closed)
|
|
102
|
+
this.fail(err);
|
|
85
103
|
}
|
|
86
104
|
return;
|
|
87
105
|
}
|
|
@@ -89,7 +107,10 @@ export class DevPulseClient {
|
|
|
89
107
|
url: () => `${this.base}/sdk/v1/client/${encodeURIComponent(this.clientSideId)}/stream?${this.query()}`,
|
|
90
108
|
fetch: this.options.fetch,
|
|
91
109
|
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
92
|
-
onOpen: () =>
|
|
110
|
+
onOpen: () => {
|
|
111
|
+
this.logger.debug("DevPulse stream connected");
|
|
112
|
+
this.setStale(false);
|
|
113
|
+
},
|
|
93
114
|
onError: (err) => {
|
|
94
115
|
this.logger.warn(`DevPulse stream error: ${err.message}`);
|
|
95
116
|
if (!err.recoverable) {
|
|
@@ -97,13 +118,23 @@ export class DevPulseClient {
|
|
|
97
118
|
return false;
|
|
98
119
|
}
|
|
99
120
|
// Keep serving flags while reconnecting; fetch once so a first load still succeeds.
|
|
121
|
+
this.setStale(true, err);
|
|
100
122
|
if (!this.initialized)
|
|
101
|
-
this.
|
|
123
|
+
this.loadFlags().catch(() => undefined);
|
|
102
124
|
return true;
|
|
103
125
|
},
|
|
104
126
|
});
|
|
105
127
|
this.stream.start();
|
|
106
128
|
}
|
|
129
|
+
/** Fetch flags for the current context and apply them unless identify() moved on (or close()) meanwhile. */
|
|
130
|
+
async loadFlags() {
|
|
131
|
+
const seq = this.contextSeq;
|
|
132
|
+
const flags = await this.fetchFlags();
|
|
133
|
+
if (seq !== this.contextSeq || this.closed)
|
|
134
|
+
return false;
|
|
135
|
+
this.replaceAll(flags);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
107
138
|
async fetchFlags() {
|
|
108
139
|
const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/client/${encodeURIComponent(this.clientSideId)}/flags?${this.query()}`);
|
|
109
140
|
if (!res.ok)
|
|
@@ -117,7 +148,7 @@ export class DevPulseClient {
|
|
|
117
148
|
this.replaceAll(payload);
|
|
118
149
|
else if (type === "patch") {
|
|
119
150
|
const { key, ...state } = payload;
|
|
120
|
-
const prev = this.
|
|
151
|
+
const prev = this.flag(key);
|
|
121
152
|
if (prev && prev.version > state.version)
|
|
122
153
|
return;
|
|
123
154
|
this.flags[key] = state;
|
|
@@ -126,7 +157,7 @@ export class DevPulseClient {
|
|
|
126
157
|
}
|
|
127
158
|
else if (type === "delete") {
|
|
128
159
|
const { key, version } = payload;
|
|
129
|
-
const prev = this.
|
|
160
|
+
const prev = this.flag(key);
|
|
130
161
|
if (!prev || prev.version > version)
|
|
131
162
|
return;
|
|
132
163
|
delete this.flags[key];
|
|
@@ -141,17 +172,20 @@ export class DevPulseClient {
|
|
|
141
172
|
replaceAll(next) {
|
|
142
173
|
const changes = {};
|
|
143
174
|
for (const key of new Set([...Object.keys(this.flags), ...Object.keys(next)])) {
|
|
144
|
-
const a = this.
|
|
145
|
-
const b = next[key]?.value;
|
|
175
|
+
const a = this.flag(key)?.value;
|
|
176
|
+
const b = Object.hasOwn(next, key) ? next[key]?.value : undefined;
|
|
146
177
|
if (JSON.stringify(a) !== JSON.stringify(b))
|
|
147
178
|
changes[key] = { current: b ?? null, previous: a };
|
|
148
179
|
}
|
|
149
|
-
this.flags =
|
|
180
|
+
this.flags = flagMap(next);
|
|
150
181
|
this.writeCache();
|
|
151
182
|
this.markReady();
|
|
152
183
|
if (Object.keys(changes).length)
|
|
153
184
|
this.emitChanges(changes);
|
|
154
185
|
}
|
|
186
|
+
flag(key) {
|
|
187
|
+
return Object.hasOwn(this.flags, key) ? this.flags[key] : undefined;
|
|
188
|
+
}
|
|
155
189
|
markReady() {
|
|
156
190
|
if (this.initialized)
|
|
157
191
|
return;
|
|
@@ -159,6 +193,16 @@ export class DevPulseClient {
|
|
|
159
193
|
this.resolveReady();
|
|
160
194
|
this.emit("ready");
|
|
161
195
|
}
|
|
196
|
+
/** "stale" when the stream drops after loading (flags keep serving), "recovered" once it reconnects. */
|
|
197
|
+
setStale(stale, err) {
|
|
198
|
+
if (!this.initialized || this.closed || this.stale === stale)
|
|
199
|
+
return;
|
|
200
|
+
this.stale = stale;
|
|
201
|
+
if (stale)
|
|
202
|
+
this.emit("stale", err);
|
|
203
|
+
else
|
|
204
|
+
this.emit("recovered");
|
|
205
|
+
}
|
|
162
206
|
fail(err) {
|
|
163
207
|
if (!this.initialized)
|
|
164
208
|
this.rejectReady(err);
|
|
@@ -205,11 +249,21 @@ export class DevPulseClient {
|
|
|
205
249
|
get isInitialized() {
|
|
206
250
|
return this.initialized;
|
|
207
251
|
}
|
|
252
|
+
/** True after close(); a closed client never updates again. */
|
|
253
|
+
get isClosed() {
|
|
254
|
+
return this.closed;
|
|
255
|
+
}
|
|
208
256
|
variation(key, defaultValue) {
|
|
209
257
|
return this.variationDetail(key, defaultValue).value;
|
|
210
258
|
}
|
|
211
259
|
variationDetail(key, defaultValue) {
|
|
212
|
-
const
|
|
260
|
+
const detail = this.peekVariationDetail(key, defaultValue);
|
|
261
|
+
this.recordEvaluation(key, defaultValue, detail);
|
|
262
|
+
return detail;
|
|
263
|
+
}
|
|
264
|
+
/** @internal Evaluate without recording an analytics event (React hooks record from an effect). */
|
|
265
|
+
peekVariationDetail(key, defaultValue) {
|
|
266
|
+
const f = this.flag(key);
|
|
213
267
|
let detail;
|
|
214
268
|
if (!f)
|
|
215
269
|
detail = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: this.initialized ? "FLAG_NOT_FOUND" : "CLIENT_NOT_READY" } };
|
|
@@ -220,9 +274,13 @@ export class DevPulseClient {
|
|
|
220
274
|
detail = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "WRONG_TYPE" } };
|
|
221
275
|
else
|
|
222
276
|
detail = { value: f.value, variationIndex: f.variation, reason: f.reason ?? null };
|
|
223
|
-
this.events?.recordEvaluation({ key, context: this.context, value: detail.value, default: defaultValue, variation: detail.variationIndex, version: f?.version, reason: f?.reason, trackEvents: f?.trackEvents });
|
|
224
277
|
return detail;
|
|
225
278
|
}
|
|
279
|
+
/** @internal Record the analytics event for an evaluation made with peekVariationDetail(). */
|
|
280
|
+
recordEvaluation(key, defaultValue, detail) {
|
|
281
|
+
const f = this.flag(key);
|
|
282
|
+
this.events?.recordEvaluation({ key, context: this.context, value: detail.value, default: defaultValue, variation: detail.variationIndex, version: f?.version, reason: f?.reason, trackEvents: f?.trackEvents });
|
|
283
|
+
}
|
|
226
284
|
boolVariation(key, defaultValue) {
|
|
227
285
|
return this.variation(key, defaultValue);
|
|
228
286
|
}
|
|
@@ -239,14 +297,21 @@ export class DevPulseClient {
|
|
|
239
297
|
allFlags() {
|
|
240
298
|
return Object.fromEntries(Object.entries(this.flags).map(([k, v]) => [k, v.value]));
|
|
241
299
|
}
|
|
242
|
-
/**
|
|
300
|
+
/**
|
|
301
|
+
* Switch to a different context (e.g. after login). Resolves with the new flags. When calls
|
|
302
|
+
* overlap, the last one wins: an earlier call's late response is ignored.
|
|
303
|
+
*/
|
|
243
304
|
async identify(context, opts = {}) {
|
|
305
|
+
this.contextSeq++;
|
|
244
306
|
this.context = context;
|
|
245
307
|
if (opts.hash !== undefined)
|
|
246
308
|
this.options.hash = opts.hash;
|
|
309
|
+
if (this.options.offline || this.closed)
|
|
310
|
+
return this.allFlags();
|
|
247
311
|
this.events?.identify(context);
|
|
248
|
-
|
|
312
|
+
// Drop the old context's stream now, so none of its events land after the switch.
|
|
249
313
|
this.stream?.restart();
|
|
314
|
+
await this.loadFlags();
|
|
250
315
|
return this.allFlags();
|
|
251
316
|
}
|
|
252
317
|
getContext() {
|
|
@@ -256,6 +321,10 @@ export class DevPulseClient {
|
|
|
256
321
|
track(key, data, metricValue) {
|
|
257
322
|
this.events?.track(key, this.context, data, metricValue);
|
|
258
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Events: "ready", "failed" (fatal, e.g. an invalid client-side ID), "change" / "change:<key>",
|
|
326
|
+
* "stale" (stream lost; the last flags keep serving) and "recovered".
|
|
327
|
+
*/
|
|
259
328
|
on(event, fn) {
|
|
260
329
|
let set = this.listeners.get(event);
|
|
261
330
|
if (!set)
|
|
@@ -283,6 +352,8 @@ export class DevPulseClient {
|
|
|
283
352
|
if (this.closed)
|
|
284
353
|
return;
|
|
285
354
|
this.closed = true;
|
|
355
|
+
this.detachVisibility?.();
|
|
356
|
+
this.detachVisibility = null;
|
|
286
357
|
this.stream?.stop();
|
|
287
358
|
await this.events?.close();
|
|
288
359
|
this.listeners.clear();
|
|
@@ -55,5 +55,14 @@ export declare class EventProcessor {
|
|
|
55
55
|
private push;
|
|
56
56
|
private drain;
|
|
57
57
|
flush(): Promise<void>;
|
|
58
|
+
private send;
|
|
58
59
|
close(): Promise<void>;
|
|
59
60
|
}
|
|
61
|
+
/** DevPulse accepts at most this many events per POST (the rest are silently dropped). */
|
|
62
|
+
export declare const MAX_EVENTS_PER_REQUEST = 5000;
|
|
63
|
+
/** Browser event bodies are capped at 512 KB by DevPulse; stay under it with room for brackets. */
|
|
64
|
+
export declare const MAX_REQUEST_BYTES = 500000;
|
|
65
|
+
/** keepalive / sendBeacon bodies must fit in the browser's 64 KiB in-flight budget. */
|
|
66
|
+
export declare const KEEPALIVE_MAX_BYTES = 60000;
|
|
67
|
+
/** Split events into JSON array bodies of at most MAX_EVENTS_PER_REQUEST events and MAX_REQUEST_BYTES bytes. */
|
|
68
|
+
export declare function chunkEvents(events: Record<string, unknown>[], logger?: Logger, maxBytes?: number, maxEvents?: number): string[];
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { parseContext } from "../engine/index.js";
|
|
2
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
|
+
}
|
|
3
21
|
/**
|
|
4
22
|
* Buffers analytics: per-flag summaries (counts per variation), context
|
|
5
23
|
* index events (deduplicated), full feature events for tracked flags and
|
|
@@ -35,6 +53,14 @@ export class EventProcessor {
|
|
|
35
53
|
else
|
|
36
54
|
out[k] = v;
|
|
37
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
|
+
}
|
|
38
64
|
if (redacted.length)
|
|
39
65
|
out.redactedAttributes = redacted;
|
|
40
66
|
return out;
|
|
@@ -140,10 +166,18 @@ export class EventProcessor {
|
|
|
140
166
|
const events = this.drain();
|
|
141
167
|
if (!events.length)
|
|
142
168
|
return;
|
|
143
|
-
const body
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
169
|
+
for (const body of chunkEvents(events, this.logger)) {
|
|
170
|
+
if (this.disabled)
|
|
171
|
+
return;
|
|
172
|
+
await this.send(body);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async send(body) {
|
|
176
|
+
const bytes = byteLength(body);
|
|
177
|
+
const g = globalThis;
|
|
178
|
+
// Beacons and keepalive requests share a 64 KiB in-flight budget in browsers; bigger bodies fail outright.
|
|
179
|
+
if (this.opts.beacon && g.navigator?.sendBeacon && g.document?.visibilityState === "hidden" && bytes < KEEPALIVE_MAX_BYTES) {
|
|
180
|
+
if (g.navigator.sendBeacon(this.opts.url, body))
|
|
147
181
|
return;
|
|
148
182
|
}
|
|
149
183
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -152,7 +186,7 @@ export class EventProcessor {
|
|
|
152
186
|
method: "POST",
|
|
153
187
|
headers: { "content-type": "application/json", ...this.opts.headers },
|
|
154
188
|
body,
|
|
155
|
-
keepalive:
|
|
189
|
+
keepalive: bytes < KEEPALIVE_MAX_BYTES,
|
|
156
190
|
});
|
|
157
191
|
if (res.ok)
|
|
158
192
|
return;
|
|
@@ -167,7 +201,8 @@ export class EventProcessor {
|
|
|
167
201
|
if (attempt === 1)
|
|
168
202
|
this.logger.warn("Failed to send events", err);
|
|
169
203
|
}
|
|
170
|
-
|
|
204
|
+
if (attempt === 0)
|
|
205
|
+
await new Promise((r) => unrefTimer(setTimeout(r, 1000)));
|
|
171
206
|
}
|
|
172
207
|
}
|
|
173
208
|
async close() {
|
|
@@ -176,3 +211,39 @@ export class EventProcessor {
|
|
|
176
211
|
await this.flush();
|
|
177
212
|
}
|
|
178
213
|
}
|
|
214
|
+
/** DevPulse accepts at most this many events per POST (the rest are silently dropped). */
|
|
215
|
+
export const MAX_EVENTS_PER_REQUEST = 5000;
|
|
216
|
+
/** Browser event bodies are capped at 512 KB by DevPulse; stay under it with room for brackets. */
|
|
217
|
+
export const MAX_REQUEST_BYTES = 500_000;
|
|
218
|
+
/** keepalive / sendBeacon bodies must fit in the browser's 64 KiB in-flight budget. */
|
|
219
|
+
export const KEEPALIVE_MAX_BYTES = 60_000;
|
|
220
|
+
const encoder = new TextEncoder();
|
|
221
|
+
const byteLength = (s) => encoder.encode(s).length;
|
|
222
|
+
function unrefTimer(t) {
|
|
223
|
+
t.unref?.();
|
|
224
|
+
return t;
|
|
225
|
+
}
|
|
226
|
+
/** Split events into JSON array bodies of at most MAX_EVENTS_PER_REQUEST events and MAX_REQUEST_BYTES bytes. */
|
|
227
|
+
export function chunkEvents(events, logger = noopLogger, maxBytes = MAX_REQUEST_BYTES, maxEvents = MAX_EVENTS_PER_REQUEST) {
|
|
228
|
+
const bodies = [];
|
|
229
|
+
let parts = [];
|
|
230
|
+
let size = 2; // "[" + "]"
|
|
231
|
+
for (const e of events) {
|
|
232
|
+
const json = JSON.stringify(e);
|
|
233
|
+
const n = byteLength(json);
|
|
234
|
+
if (n + 2 > maxBytes) {
|
|
235
|
+
logger.warn(`Dropping a ${String(e.kind)} event of ${n} bytes (over the ${maxBytes}-byte request limit)`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (parts.length && (parts.length >= maxEvents || size + n + 1 > maxBytes)) {
|
|
239
|
+
bodies.push(`[${parts.join(",")}]`);
|
|
240
|
+
parts = [];
|
|
241
|
+
size = 2;
|
|
242
|
+
}
|
|
243
|
+
size += n + (parts.length ? 1 : 0);
|
|
244
|
+
parts.push(json);
|
|
245
|
+
}
|
|
246
|
+
if (parts.length)
|
|
247
|
+
bodies.push(`[${parts.join(",")}]`);
|
|
248
|
+
return bodies;
|
|
249
|
+
}
|
|
@@ -1,5 +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";
|
|
1
|
+
export { SseParser, SseClient, SseError, backoffDelay, DEFAULT_MAX_EVENT_SIZE, MIN_SERVER_RETRY_MS, type SseEvent, type SseClientOptions } from "./sse.js";
|
|
2
|
+
export { EventProcessor, noopLogger, chunkEvents, MAX_EVENTS_PER_REQUEST, MAX_REQUEST_BYTES, KEEPALIVE_MAX_BYTES, type EventProcessorOptions, type Logger } from "./events.js";
|
|
3
3
|
/** Encode a context for URLs (base64url JSON), matching the DevPulse SDK endpoints. */
|
|
4
4
|
export declare function encodeContext(context: unknown): string;
|
|
5
|
-
export
|
|
5
|
+
export { SDK_VERSION } from "./version.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { SseParser, SseClient, SseError, backoffDelay } from "./sse.js";
|
|
2
|
-
export { EventProcessor, noopLogger } from "./events.js";
|
|
1
|
+
export { SseParser, SseClient, SseError, backoffDelay, DEFAULT_MAX_EVENT_SIZE, MIN_SERVER_RETRY_MS } from "./sse.js";
|
|
2
|
+
export { EventProcessor, noopLogger, chunkEvents, MAX_EVENTS_PER_REQUEST, MAX_REQUEST_BYTES, KEEPALIVE_MAX_BYTES } from "./events.js";
|
|
3
3
|
/** Encode a context for URLs (base64url JSON), matching the DevPulse SDK endpoints. */
|
|
4
4
|
export function encodeContext(context) {
|
|
5
5
|
const json = JSON.stringify(context);
|
|
@@ -9,4 +9,4 @@ export function encodeContext(context) {
|
|
|
9
9
|
bin += String.fromCharCode(b);
|
|
10
10
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
11
|
}
|
|
12
|
-
export
|
|
12
|
+
export { SDK_VERSION } from "./version.js";
|
|
@@ -9,16 +9,25 @@ export interface SseEvent {
|
|
|
9
9
|
data: string;
|
|
10
10
|
lastEventId: string;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
12
|
+
/** Default cap on one event (its pending line plus its `data` lines), in UTF-16 code units. */
|
|
13
|
+
export declare const DEFAULT_MAX_EVENT_SIZE: number;
|
|
14
|
+
/**
|
|
15
|
+
* Incremental parser: feed it chunks of text, get dispatched events back. Use one parser per
|
|
16
|
+
* connection: a dropped connection can leave a half-received event behind.
|
|
17
|
+
*/
|
|
13
18
|
export declare class SseParser {
|
|
19
|
+
private readonly maxEventSize;
|
|
14
20
|
private buffer;
|
|
15
21
|
private data;
|
|
22
|
+
private dataSize;
|
|
16
23
|
private eventType;
|
|
17
24
|
private sawFirstChunk;
|
|
18
25
|
/** The previous chunk ended in CR, so an LF starting this chunk completes a CRLF. */
|
|
19
26
|
private pendingCR;
|
|
20
27
|
lastEventId: string;
|
|
21
28
|
retry: number | null;
|
|
29
|
+
/** @param maxEventSize feed() throws an SseError once an event grows past this (bounded memory). */
|
|
30
|
+
constructor(maxEventSize?: number);
|
|
22
31
|
feed(chunk: string): SseEvent[];
|
|
23
32
|
private line;
|
|
24
33
|
}
|
|
@@ -35,7 +44,16 @@ export interface SseClientOptions {
|
|
|
35
44
|
maxRetryMs?: number;
|
|
36
45
|
/** Reconnect if nothing (not even a heartbeat) arrives for this long. */
|
|
37
46
|
readTimeoutMs?: number;
|
|
47
|
+
/** Largest single event accepted (default 1 MB); a bigger one drops the connection. */
|
|
48
|
+
maxEventSize?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Send only CORS-safelisted request headers: no `cache-control` or `last-event-id`, which
|
|
51
|
+
* would trigger a preflight that DevPulse doesn't allow. Default: true in browsers and workers.
|
|
52
|
+
*/
|
|
53
|
+
browser?: boolean;
|
|
38
54
|
}
|
|
55
|
+
/** Lower bound for a server-sent `retry:` hint, so `retry: 0` can't cause a reconnect storm. */
|
|
56
|
+
export declare const MIN_SERVER_RETRY_MS = 1000;
|
|
39
57
|
export declare class SseError extends Error {
|
|
40
58
|
readonly status?: number | undefined;
|
|
41
59
|
constructor(message: string, status?: number | undefined);
|
|
@@ -49,14 +67,17 @@ export declare class SseClient {
|
|
|
49
67
|
private controller;
|
|
50
68
|
private stopped;
|
|
51
69
|
private attempt;
|
|
52
|
-
|
|
70
|
+
/** Carried across connections; the parser itself is fresh per connection. */
|
|
71
|
+
private lastEventId;
|
|
72
|
+
private retry;
|
|
53
73
|
private timer;
|
|
54
74
|
private watchdog;
|
|
55
75
|
private reader;
|
|
56
76
|
constructor(opts: SseClientOptions);
|
|
57
77
|
start(): void;
|
|
58
78
|
stop(): void;
|
|
59
|
-
/** Drop the current connection and reconnect immediately (e.g. after identify). */
|
|
79
|
+
/** Drop the current connection (or pending reconnect) and reconnect immediately (e.g. after identify). */
|
|
60
80
|
restart(): void;
|
|
81
|
+
private clearTimer;
|
|
61
82
|
private connect;
|
|
62
83
|
}
|