@shelbyb/daywalker-sdk 0.3.0 → 0.4.1
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 +64 -23
- package/dist/analytics/index.d.ts +16 -2
- package/dist/analytics/index.js +79 -14
- package/dist/cli.js +133 -31
- package/dist/client.d.ts +29 -2
- package/dist/client.js +314 -62
- package/dist/config.d.ts +8 -2
- package/dist/config.js +14 -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 +42 -12
- package/dist/crypto/signatures.js +236 -79
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +32 -4
- package/dist/flags/client.js +74 -12
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +51 -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/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +20 -3
- package/dist/flags/engine/operators.js +38 -32
- package/dist/flags/node.d.ts +26 -14
- package/dist/flags/node.js +51 -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 +52 -8
- package/dist/next.js +151 -37
- 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 +56 -4
- 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 +32 -14
- package/assets/logo-512.png +0 -0
package/dist/flags/common/sse.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Default cap on one event (its pending line plus its `data` lines), in UTF-16 code units. */
|
|
2
|
+
export const DEFAULT_MAX_EVENT_SIZE = 1024 * 1024;
|
|
3
|
+
/**
|
|
4
|
+
* Incremental parser: feed it chunks of text, get dispatched events back. Use one parser per
|
|
5
|
+
* connection: a dropped connection can leave a half-received event behind.
|
|
6
|
+
*/
|
|
2
7
|
export class SseParser {
|
|
8
|
+
maxEventSize;
|
|
3
9
|
buffer = "";
|
|
4
10
|
data = [];
|
|
11
|
+
dataSize = 0;
|
|
5
12
|
eventType = "";
|
|
6
13
|
sawFirstChunk = false;
|
|
7
14
|
/** The previous chunk ended in CR, so an LF starting this chunk completes a CRLF. */
|
|
8
15
|
pendingCR = false;
|
|
9
16
|
lastEventId = "";
|
|
10
17
|
retry = null;
|
|
18
|
+
/** @param maxEventSize feed() throws an SseError once an event grows past this (bounded memory). */
|
|
19
|
+
constructor(maxEventSize = DEFAULT_MAX_EVENT_SIZE) {
|
|
20
|
+
this.maxEventSize = maxEventSize;
|
|
21
|
+
}
|
|
11
22
|
feed(chunk) {
|
|
12
23
|
if (!this.sawFirstChunk) {
|
|
13
24
|
this.sawFirstChunk = true;
|
|
@@ -39,6 +50,12 @@ export class SseParser {
|
|
|
39
50
|
out.push(ev);
|
|
40
51
|
}
|
|
41
52
|
this.buffer = this.buffer.slice(start);
|
|
53
|
+
if (this.buffer.length + this.dataSize > this.maxEventSize) {
|
|
54
|
+
this.buffer = "";
|
|
55
|
+
this.data = [];
|
|
56
|
+
this.dataSize = 0;
|
|
57
|
+
throw new SseError(`Stream event exceeded ${this.maxEventSize} characters`);
|
|
58
|
+
}
|
|
42
59
|
return out;
|
|
43
60
|
}
|
|
44
61
|
line(line) {
|
|
@@ -49,6 +66,7 @@ export class SseParser {
|
|
|
49
66
|
}
|
|
50
67
|
const ev = { type: this.eventType || "message", data: this.data.join("\n"), lastEventId: this.lastEventId };
|
|
51
68
|
this.data = [];
|
|
69
|
+
this.dataSize = 0;
|
|
52
70
|
this.eventType = "";
|
|
53
71
|
return ev;
|
|
54
72
|
}
|
|
@@ -65,6 +83,12 @@ export class SseParser {
|
|
|
65
83
|
break;
|
|
66
84
|
case "data":
|
|
67
85
|
this.data.push(value);
|
|
86
|
+
this.dataSize += value.length + 1;
|
|
87
|
+
if (this.dataSize > this.maxEventSize) {
|
|
88
|
+
this.data = [];
|
|
89
|
+
this.dataSize = 0;
|
|
90
|
+
throw new SseError(`Stream event exceeded ${this.maxEventSize} characters`);
|
|
91
|
+
}
|
|
68
92
|
break;
|
|
69
93
|
case "id":
|
|
70
94
|
if (!value.includes("\0"))
|
|
@@ -78,6 +102,18 @@ export class SseParser {
|
|
|
78
102
|
return null;
|
|
79
103
|
}
|
|
80
104
|
}
|
|
105
|
+
/** Lower bound for a server-sent `retry:` hint, so `retry: 0` can't cause a reconnect storm. */
|
|
106
|
+
export const MIN_SERVER_RETRY_MS = 1000;
|
|
107
|
+
/** Browsers and web workers enforce CORS on fetch; Node, Deno (no document), Bun and React Native don't. */
|
|
108
|
+
function isBrowserLike() {
|
|
109
|
+
const g = globalThis;
|
|
110
|
+
return typeof g.document !== "undefined" || typeof g.WorkerGlobalScope !== "undefined";
|
|
111
|
+
}
|
|
112
|
+
/** Let a timer not keep a Node/Deno/Bun process alive (no-op in browsers). */
|
|
113
|
+
function unref(t) {
|
|
114
|
+
t.unref?.();
|
|
115
|
+
return t;
|
|
116
|
+
}
|
|
81
117
|
export class SseError extends Error {
|
|
82
118
|
status;
|
|
83
119
|
constructor(message, status) {
|
|
@@ -99,7 +135,9 @@ export class SseClient {
|
|
|
99
135
|
controller = null;
|
|
100
136
|
stopped = false;
|
|
101
137
|
attempt = 0;
|
|
102
|
-
parser
|
|
138
|
+
/** Carried across connections; the parser itself is fresh per connection. */
|
|
139
|
+
lastEventId = "";
|
|
140
|
+
retry = null;
|
|
103
141
|
timer = null;
|
|
104
142
|
watchdog = null;
|
|
105
143
|
reader = null;
|
|
@@ -112,44 +150,66 @@ export class SseClient {
|
|
|
112
150
|
}
|
|
113
151
|
stop() {
|
|
114
152
|
this.stopped = true;
|
|
115
|
-
|
|
116
|
-
clearTimeout(this.timer);
|
|
153
|
+
this.clearTimer();
|
|
117
154
|
// Don't leave the read-timeout timer or a pending read keeping the process alive.
|
|
118
155
|
if (this.watchdog)
|
|
119
156
|
clearTimeout(this.watchdog);
|
|
157
|
+
this.watchdog = null;
|
|
120
158
|
this.controller?.abort();
|
|
121
159
|
void this.reader?.cancel().catch(() => { });
|
|
122
160
|
}
|
|
123
|
-
/** Drop the current connection and reconnect immediately (e.g. after identify). */
|
|
161
|
+
/** Drop the current connection (or pending reconnect) and reconnect immediately (e.g. after identify). */
|
|
124
162
|
restart() {
|
|
163
|
+
this.clearTimer();
|
|
125
164
|
this.controller?.abort();
|
|
126
165
|
this.attempt = 0;
|
|
127
|
-
this.
|
|
166
|
+
this.lastEventId = ""; // the old stream's ids mean nothing to a new context
|
|
128
167
|
if (!this.stopped)
|
|
129
168
|
void this.connect();
|
|
130
169
|
}
|
|
170
|
+
clearTimer() {
|
|
171
|
+
if (this.timer)
|
|
172
|
+
clearTimeout(this.timer);
|
|
173
|
+
this.timer = null;
|
|
174
|
+
}
|
|
131
175
|
async connect() {
|
|
132
176
|
if (this.stopped)
|
|
133
177
|
return;
|
|
178
|
+
this.clearTimer();
|
|
179
|
+
this.controller?.abort(); // never two live connections
|
|
134
180
|
const controller = new AbortController();
|
|
135
181
|
this.controller = controller;
|
|
182
|
+
const parser = new SseParser(this.opts.maxEventSize);
|
|
183
|
+
parser.lastEventId = this.lastEventId;
|
|
184
|
+
parser.retry = this.retry;
|
|
136
185
|
const f = this.opts.fetch ?? fetch;
|
|
137
186
|
const resetWatchdog = () => {
|
|
138
187
|
if (this.watchdog)
|
|
139
188
|
clearTimeout(this.watchdog);
|
|
140
|
-
this.watchdog = setTimeout(() => controller.abort(), this.opts.readTimeoutMs ?? 5 * 60_000);
|
|
189
|
+
this.watchdog = unref(setTimeout(() => controller.abort(), this.opts.readTimeoutMs ?? 5 * 60_000));
|
|
141
190
|
};
|
|
142
191
|
const connectedAt = Date.now();
|
|
143
192
|
try {
|
|
144
193
|
const url = typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
|
|
145
194
|
const headers = typeof this.opts.headers === "function" ? this.opts.headers() : (this.opts.headers ?? {});
|
|
195
|
+
const browser = this.opts.browser ?? isBrowserLike();
|
|
146
196
|
resetWatchdog();
|
|
147
197
|
const res = await f(url, {
|
|
148
|
-
headers: {
|
|
198
|
+
headers: {
|
|
199
|
+
accept: "text/event-stream",
|
|
200
|
+
...(browser ? {} : { "cache-control": "no-cache", ...(this.lastEventId ? { "last-event-id": this.lastEventId } : {}) }),
|
|
201
|
+
...headers,
|
|
202
|
+
},
|
|
203
|
+
// Browsers: keep the HTTP cache out of it without a non-safelisted request header.
|
|
204
|
+
...(browser ? { cache: "no-store" } : {}),
|
|
149
205
|
signal: controller.signal,
|
|
150
206
|
});
|
|
151
207
|
if (!res.ok || !res.body)
|
|
152
208
|
throw new SseError(`Stream request failed with ${res.status}`, res.status);
|
|
209
|
+
if (this.controller !== controller) {
|
|
210
|
+
void res.body.cancel().catch(() => { });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
153
213
|
this.opts.onOpen?.();
|
|
154
214
|
const reader = res.body.getReader();
|
|
155
215
|
this.reader = reader;
|
|
@@ -159,25 +219,35 @@ export class SseClient {
|
|
|
159
219
|
if (done)
|
|
160
220
|
break;
|
|
161
221
|
resetWatchdog();
|
|
162
|
-
|
|
222
|
+
const events = parser.feed(decoder.decode(value, { stream: true }));
|
|
223
|
+
this.lastEventId = parser.lastEventId;
|
|
224
|
+
this.retry = parser.retry;
|
|
225
|
+
for (const ev of events) {
|
|
226
|
+
if (this.controller !== controller)
|
|
227
|
+
return; // superseded by restart()/stop() from a handler
|
|
163
228
|
this.opts.onEvent(ev);
|
|
229
|
+
}
|
|
164
230
|
}
|
|
165
231
|
throw new SseError("Stream closed by server");
|
|
166
232
|
}
|
|
167
233
|
catch (err) {
|
|
168
|
-
if (this.watchdog)
|
|
234
|
+
if (this.controller === controller && this.watchdog)
|
|
169
235
|
clearTimeout(this.watchdog);
|
|
170
|
-
if (this.stopped ||
|
|
236
|
+
if (this.stopped || this.controller !== controller)
|
|
171
237
|
return;
|
|
238
|
+
controller.abort(); // release the response body
|
|
172
239
|
const e = err instanceof SseError ? err : new SseError(err?.message ?? "Stream error");
|
|
173
240
|
const keepGoing = this.opts.onError?.(e);
|
|
174
|
-
if (keepGoing === false || !e.recoverable)
|
|
241
|
+
if (keepGoing === false || !e.recoverable || this.stopped || this.controller !== controller)
|
|
175
242
|
return;
|
|
176
243
|
if (Date.now() - connectedAt > 60_000)
|
|
177
244
|
this.attempt = 0; // was healthy for a while
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
245
|
+
const fromServer = this.retry !== null;
|
|
246
|
+
const base = fromServer ? Math.max(MIN_SERVER_RETRY_MS, this.retry) : (this.opts.initialRetryMs ?? 1000);
|
|
247
|
+
let delay = backoffDelay(this.attempt++, base, this.opts.maxRetryMs ?? 30_000);
|
|
248
|
+
if (fromServer)
|
|
249
|
+
delay = Math.max(MIN_SERVER_RETRY_MS, delay);
|
|
250
|
+
this.timer = unref(setTimeout(() => void this.connect(), delay));
|
|
181
251
|
}
|
|
182
252
|
}
|
|
183
253
|
}
|
|
@@ -2,6 +2,6 @@ export * from "./types.js";
|
|
|
2
2
|
export { parseContext, getAttribute, ContextError, type Context, type ContextInput, type SingleContext } from "./context.js";
|
|
3
3
|
export { Evaluator, MemoryStore, evaluateFlag, errorDetail, type EvaluationResult, type PrerequisiteEvaluation } from "./evaluate.js";
|
|
4
4
|
export { bucketContext, variationForRollout } from "./bucket.js";
|
|
5
|
-
export { applyOperator, parseTime } from "./operators.js";
|
|
5
|
+
export { applyOperator, parseTime, isSafePattern, compilePattern, MAX_PATTERN_LENGTH, MAX_MATCH_INPUT, MAX_MATCH_PATTERN_LENGTH, MAX_MATCH_INPUT_LENGTH, type SafeRegex } from "./operators.js";
|
|
6
6
|
export { parseSemVer, compareSemVer } from "./semver.js";
|
|
7
7
|
export { sha1Hex } from "./sha1.js";
|
|
@@ -2,6 +2,6 @@ export * from "./types.js";
|
|
|
2
2
|
export { parseContext, getAttribute, ContextError } from "./context.js";
|
|
3
3
|
export { Evaluator, MemoryStore, evaluateFlag, errorDetail } from "./evaluate.js";
|
|
4
4
|
export { bucketContext, variationForRollout } from "./bucket.js";
|
|
5
|
-
export { applyOperator, parseTime } from "./operators.js";
|
|
5
|
+
export { applyOperator, parseTime, isSafePattern, compilePattern, MAX_PATTERN_LENGTH, MAX_MATCH_INPUT, MAX_MATCH_PATTERN_LENGTH, MAX_MATCH_INPUT_LENGTH } from "./operators.js";
|
|
6
6
|
export { parseSemVer, compareSemVer } from "./semver.js";
|
|
7
7
|
export { sha1Hex } from "./sha1.js";
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import type { FlagValue, Operator } from "./types.js";
|
|
2
|
-
/** `matches` limits: longer inputs or patterns never match. */
|
|
2
|
+
/** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
|
|
3
3
|
export declare const MAX_MATCH_INPUT_LENGTH = 1024;
|
|
4
4
|
export declare const MAX_MATCH_PATTERN_LENGTH = 500;
|
|
5
|
+
/** Server-parity aliases (`@devpulse/flag-engine` names). */
|
|
6
|
+
export declare const MAX_PATTERN_LENGTH = 500;
|
|
7
|
+
export declare const MAX_MATCH_INPUT = 1024;
|
|
8
|
+
/** A compiled user pattern: only `test()` is exposed. */
|
|
9
|
+
export type SafeRegex = {
|
|
10
|
+
test(input: string): boolean;
|
|
11
|
+
};
|
|
5
12
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
13
|
+
* Compile a user-supplied pattern with RE2 (linear-time matching, no backtracking), so no
|
|
14
|
+
* pattern can stall the process however it's written (ReDoS), and every SDK agrees with the
|
|
15
|
+
* DevPulse server on what matches. RE2 has no backreferences or lookaround; patterns using
|
|
16
|
+
* them don't compile and return null (never match).
|
|
17
|
+
*/
|
|
18
|
+
export declare function compilePattern(pattern: string): SafeRegex | null;
|
|
19
|
+
/** Whether a `matches` pattern is accepted: bounded length and valid RE2 syntax. */
|
|
20
|
+
export declare function isSafePattern(pattern: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* @deprecated No longer used: `matches` runs on RE2, which is immune to ReDoS. Kept for
|
|
23
|
+
* backwards compatibility. Conservative heuristic for a group that contains a quantifier or
|
|
24
|
+
* alternation and is itself repeated (`(a+)+`, `(a|aa)+`, `(a+){2,}`).
|
|
8
25
|
*/
|
|
9
26
|
export declare function hasNestedQuantifier(pattern: string): boolean;
|
|
10
27
|
/** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
|
|
@@ -1,12 +1,43 @@
|
|
|
1
|
+
import { RE2JS } from "re2js";
|
|
1
2
|
import { compareSemVer, parseSemVer } from "./semver.js";
|
|
2
|
-
|
|
3
|
-
/** `matches` limits: longer inputs or patterns never match. */
|
|
3
|
+
/** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
|
|
4
4
|
export const MAX_MATCH_INPUT_LENGTH = 1024;
|
|
5
5
|
export const MAX_MATCH_PATTERN_LENGTH = 500;
|
|
6
|
-
|
|
6
|
+
/** Server-parity aliases (`@devpulse/flag-engine` names). */
|
|
7
|
+
export const MAX_PATTERN_LENGTH = MAX_MATCH_PATTERN_LENGTH;
|
|
8
|
+
export const MAX_MATCH_INPUT = MAX_MATCH_INPUT_LENGTH;
|
|
9
|
+
const regexCache = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Compile a user-supplied pattern with RE2 (linear-time matching, no backtracking), so no
|
|
12
|
+
* pattern can stall the process however it's written (ReDoS), and every SDK agrees with the
|
|
13
|
+
* DevPulse server on what matches. RE2 has no backreferences or lookaround; patterns using
|
|
14
|
+
* them don't compile and return null (never match).
|
|
15
|
+
*/
|
|
16
|
+
export function compilePattern(pattern) {
|
|
17
|
+
if (!regexCache.has(pattern)) {
|
|
18
|
+
let re = null;
|
|
19
|
+
if (pattern.length <= MAX_MATCH_PATTERN_LENGTH) {
|
|
20
|
+
try {
|
|
21
|
+
re = RE2JS.compile(pattern);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
re = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (regexCache.size > 1000)
|
|
28
|
+
regexCache.clear();
|
|
29
|
+
regexCache.set(pattern, re);
|
|
30
|
+
}
|
|
31
|
+
return regexCache.get(pattern);
|
|
32
|
+
}
|
|
33
|
+
/** Whether a `matches` pattern is accepted: bounded length and valid RE2 syntax. */
|
|
34
|
+
export function isSafePattern(pattern) {
|
|
35
|
+
return compilePattern(pattern) !== null;
|
|
36
|
+
}
|
|
7
37
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
38
|
+
* @deprecated No longer used: `matches` runs on RE2, which is immune to ReDoS. Kept for
|
|
39
|
+
* backwards compatibility. Conservative heuristic for a group that contains a quantifier or
|
|
40
|
+
* alternation and is itself repeated (`(a+)+`, `(a|aa)+`, `(a+){2,}`).
|
|
10
41
|
*/
|
|
11
42
|
export function hasNestedQuantifier(pattern) {
|
|
12
43
|
const stack = []; // per open group: contains a quantifier / alternation
|
|
@@ -40,29 +71,6 @@ export function hasNestedQuantifier(pattern) {
|
|
|
40
71
|
}
|
|
41
72
|
return false;
|
|
42
73
|
}
|
|
43
|
-
function regex(pattern) {
|
|
44
|
-
if (!regexCache.has(pattern)) {
|
|
45
|
-
let re = null;
|
|
46
|
-
if (pattern.length > MAX_MATCH_PATTERN_LENGTH || hasNestedQuantifier(pattern)) {
|
|
47
|
-
if (!warnedUnsafePattern) {
|
|
48
|
-
warnedUnsafePattern = true;
|
|
49
|
-
console.warn("DevPulse: a `matches` pattern was too long or had nested quantifiers and is treated as no match");
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
else {
|
|
53
|
-
try {
|
|
54
|
-
re = new RegExp(pattern);
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
re = null;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (regexCache.size > 1000)
|
|
61
|
-
regexCache.clear();
|
|
62
|
-
regexCache.set(pattern, re);
|
|
63
|
-
}
|
|
64
|
-
return regexCache.get(pattern);
|
|
65
|
-
}
|
|
66
74
|
/** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
|
|
67
75
|
export function parseTime(v) {
|
|
68
76
|
if (typeof v === "number" && Number.isFinite(v))
|
|
@@ -97,11 +105,9 @@ export function applyOperator(op, actual, expected) {
|
|
|
97
105
|
case "contains":
|
|
98
106
|
return typeof actual === "string" && typeof expected === "string" && actual.includes(expected);
|
|
99
107
|
case "matches": {
|
|
100
|
-
if (typeof actual !== "string" || typeof expected !== "string")
|
|
101
|
-
return false;
|
|
102
|
-
if (actual.length > MAX_MATCH_INPUT_LENGTH)
|
|
108
|
+
if (typeof actual !== "string" || typeof expected !== "string" || actual.length > MAX_MATCH_INPUT_LENGTH)
|
|
103
109
|
return false;
|
|
104
|
-
return
|
|
110
|
+
return compilePattern(expected)?.test(actual) ?? false;
|
|
105
111
|
}
|
|
106
112
|
case "lessThan":
|
|
107
113
|
case "lessThanOrEqual":
|
package/dist/flags/node.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { type ContextInput, type EvaluationDetail, type FlagConfig, type FlagValue, type SegmentConfig } from "./engine/index.js";
|
|
3
|
-
import { type Logger } from "./common/index.js";
|
|
4
|
-
|
|
3
|
+
import { SDK_VERSION, type Logger } from "./common/index.js";
|
|
4
|
+
import type { FlagSet } from "./client.js";
|
|
5
|
+
export type { ContextInput, EvaluationDetail, FlagSet, FlagValue, Logger };
|
|
6
|
+
export { SDK_VERSION };
|
|
5
7
|
export interface NodeClientOptions {
|
|
6
8
|
/** Base URL of your DevPulse deployment (https required except on localhost). */
|
|
7
9
|
baseUrl: string;
|
|
@@ -26,17 +28,24 @@ export interface NodeClientOptions {
|
|
|
26
28
|
export interface AllFlagsState {
|
|
27
29
|
valid: boolean;
|
|
28
30
|
values: Record<string, FlagValue>;
|
|
29
|
-
/** Pass to the browser SDK's `bootstrap` option for flicker-free SSR. */
|
|
30
|
-
toJSON():
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
/** Pass to the browser SDK's `bootstrap` option for flicker-free SSR (same shape as the client endpoint). */
|
|
32
|
+
toJSON(): FlagSet;
|
|
33
|
+
}
|
|
34
|
+
export interface AllFlagsStateOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Only flags marked "available to client-side SDKs" (default true), so the result is safe to
|
|
37
|
+
* embed in HTML. Pass false to include every flag, e.g. for server-side use only.
|
|
38
|
+
*/
|
|
39
|
+
clientSideOnly?: boolean;
|
|
40
|
+
/** Include evaluation reasons (always included for experiments). */
|
|
41
|
+
withReasons?: boolean;
|
|
36
42
|
}
|
|
37
43
|
/**
|
|
38
44
|
* Server-side client. One instance per process and SDK key.
|
|
39
45
|
*
|
|
46
|
+
* Events: "ready", "failed" (fatal, e.g. an invalid SDK key), "update" / "update:<key>",
|
|
47
|
+
* "stale" (connection lost; the last data keeps serving) and "recovered".
|
|
48
|
+
*
|
|
40
49
|
* const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
|
|
41
50
|
* await client.waitForInitialization({ timeoutMs: 5000 });
|
|
42
51
|
* const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
|
|
@@ -54,6 +63,7 @@ export declare class DevPulseNodeClient extends EventEmitter {
|
|
|
54
63
|
private resolveReady;
|
|
55
64
|
private rejectReady;
|
|
56
65
|
private streamFailures;
|
|
66
|
+
private stale;
|
|
57
67
|
private readonly logger;
|
|
58
68
|
private readonly base;
|
|
59
69
|
constructor(sdkKey: string, options: NodeClientOptions);
|
|
@@ -65,6 +75,8 @@ export declare class DevPulseNodeClient extends EventEmitter {
|
|
|
65
75
|
private applyPut;
|
|
66
76
|
private emitUpdate;
|
|
67
77
|
private markReady;
|
|
78
|
+
/** "stale" when updates stop arriving (flags keep serving the last data), "recovered" when they resume. */
|
|
79
|
+
private setStale;
|
|
68
80
|
private fail;
|
|
69
81
|
waitForInitialization(opts?: {
|
|
70
82
|
timeoutMs?: number;
|
|
@@ -80,13 +92,13 @@ export declare class DevPulseNodeClient extends EventEmitter {
|
|
|
80
92
|
numberVariation(key: string, context: ContextInput, defaultValue: number): Promise<number>;
|
|
81
93
|
jsonVariation<T extends FlagValue>(key: string, context: ContextInput, defaultValue: T): Promise<T>;
|
|
82
94
|
/**
|
|
83
|
-
* Evaluate every flag for a context, e.g. to bootstrap a browser client
|
|
95
|
+
* Evaluate every client-side flag for a context, e.g. to bootstrap a browser client
|
|
84
96
|
* during server rendering. Doesn't record analytics.
|
|
97
|
+
*
|
|
98
|
+
* const state = await client.allFlagsState(context);
|
|
99
|
+
* createClient(id, context, { baseUrl, bootstrap: state.toJSON() });
|
|
85
100
|
*/
|
|
86
|
-
allFlagsState(context: ContextInput, opts?:
|
|
87
|
-
clientSideOnly?: boolean;
|
|
88
|
-
withReasons?: boolean;
|
|
89
|
-
}): Promise<AllFlagsState>;
|
|
101
|
+
allFlagsState(context: ContextInput, opts?: AllFlagsStateOptions): Promise<AllFlagsState>;
|
|
90
102
|
/** Secure mode hash for a context, for the browser SDK's `hash` option. */
|
|
91
103
|
secureModeHash(context: ContextInput): string;
|
|
92
104
|
track(key: string, context: ContextInput, data?: unknown, metricValue?: number): void;
|
package/dist/flags/node.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import { Evaluator, MemoryStore, evaluateFlag, parseContext } from "./engine/index.js";
|
|
4
|
-
import { EventProcessor, SseClient, noopLogger } from "./common/index.js";
|
|
4
|
+
import { EventProcessor, SDK_VERSION, SseClient, noopLogger } from "./common/index.js";
|
|
5
5
|
import { assertSecureUrl } from "../config.js";
|
|
6
|
+
export { SDK_VERSION };
|
|
6
7
|
/**
|
|
7
8
|
* Server-side client. One instance per process and SDK key.
|
|
8
9
|
*
|
|
10
|
+
* Events: "ready", "failed" (fatal, e.g. an invalid SDK key), "update" / "update:<key>",
|
|
11
|
+
* "stale" (connection lost; the last data keeps serving) and "recovered".
|
|
12
|
+
*
|
|
9
13
|
* const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
|
|
10
14
|
* await client.waitForInitialization({ timeoutMs: 5000 });
|
|
11
15
|
* const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
|
|
@@ -23,6 +27,7 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
23
27
|
resolveReady;
|
|
24
28
|
rejectReady;
|
|
25
29
|
streamFailures = 0;
|
|
30
|
+
stale = false;
|
|
26
31
|
logger;
|
|
27
32
|
base;
|
|
28
33
|
constructor(sdkKey, options) {
|
|
@@ -61,16 +66,20 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
61
66
|
this.startStreaming();
|
|
62
67
|
}
|
|
63
68
|
headers() {
|
|
64
|
-
return { authorization: this.sdkKey, "user-agent":
|
|
69
|
+
return { authorization: this.sdkKey, "user-agent": `DevPulseNode/${SDK_VERSION}` };
|
|
65
70
|
}
|
|
66
71
|
startStreaming() {
|
|
67
72
|
this.stream = new SseClient({
|
|
68
73
|
url: `${this.base}/sdk/v1/server/stream`,
|
|
69
74
|
headers: () => this.headers(),
|
|
70
75
|
fetch: this.options.fetch,
|
|
76
|
+
browser: false,
|
|
77
|
+
// Full rulesets (segments with big target lists) can be large.
|
|
78
|
+
maxEventSize: 64 * 1024 * 1024,
|
|
71
79
|
onOpen: () => {
|
|
72
80
|
this.streamFailures = 0;
|
|
73
81
|
this.stopPolling();
|
|
82
|
+
this.setStale(false);
|
|
74
83
|
},
|
|
75
84
|
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
76
85
|
onError: (err) => {
|
|
@@ -79,6 +88,9 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
79
88
|
this.fail(new Error("Invalid SDK key"));
|
|
80
89
|
return false;
|
|
81
90
|
}
|
|
91
|
+
// While polling, the poll result decides whether data is stale.
|
|
92
|
+
if (!this.poller)
|
|
93
|
+
this.setStale(true, err);
|
|
82
94
|
// After repeated failures, poll so data stays fresh while we keep retrying the stream.
|
|
83
95
|
if (++this.streamFailures >= 3)
|
|
84
96
|
this.startPolling();
|
|
@@ -94,17 +106,21 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
94
106
|
const poll = async () => {
|
|
95
107
|
try {
|
|
96
108
|
const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/server/flags`, { headers: { ...this.headers(), ...(this.etag ? { "if-none-match": this.etag } : {}) } });
|
|
97
|
-
if (res.status === 304)
|
|
109
|
+
if (res.status === 304) {
|
|
110
|
+
this.setStale(false);
|
|
98
111
|
return this.markReady();
|
|
112
|
+
}
|
|
99
113
|
if (res.status === 401 || res.status === 403)
|
|
100
114
|
return this.fail(new Error("Invalid SDK key"));
|
|
101
115
|
if (!res.ok)
|
|
102
116
|
throw new Error(`Poll failed with ${res.status}`);
|
|
103
117
|
this.etag = res.headers.get("etag");
|
|
104
118
|
this.applyPut((await res.json()));
|
|
119
|
+
this.setStale(false);
|
|
105
120
|
}
|
|
106
121
|
catch (err) {
|
|
107
122
|
this.logger.warn("DevPulse poll failed", err);
|
|
123
|
+
this.setStale(true, err);
|
|
108
124
|
}
|
|
109
125
|
};
|
|
110
126
|
this.poller = setInterval(poll, interval);
|
|
@@ -163,6 +179,16 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
163
179
|
this.resolveReady();
|
|
164
180
|
this.emit("ready");
|
|
165
181
|
}
|
|
182
|
+
/** "stale" when updates stop arriving (flags keep serving the last data), "recovered" when they resume. */
|
|
183
|
+
setStale(stale, err) {
|
|
184
|
+
if (!this.initialized || this.stale === stale)
|
|
185
|
+
return;
|
|
186
|
+
this.stale = stale;
|
|
187
|
+
if (stale)
|
|
188
|
+
this.emit("stale", err);
|
|
189
|
+
else
|
|
190
|
+
this.emit("recovered");
|
|
191
|
+
}
|
|
166
192
|
fail(err) {
|
|
167
193
|
if (!this.initialized)
|
|
168
194
|
this.rejectReady(err);
|
|
@@ -173,7 +199,12 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
173
199
|
const ready = this.ready.then(() => this);
|
|
174
200
|
if (!opts.timeoutMs)
|
|
175
201
|
return ready;
|
|
176
|
-
|
|
202
|
+
let timer;
|
|
203
|
+
const timeout = new Promise((_, rej) => {
|
|
204
|
+
// Not unref'd: someone is awaiting it. Cleared as soon as either side settles.
|
|
205
|
+
timer = setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs);
|
|
206
|
+
});
|
|
207
|
+
return Promise.race([ready, timeout]).finally(() => clearTimeout(timer));
|
|
177
208
|
}
|
|
178
209
|
get isInitialized() {
|
|
179
210
|
return this.initialized;
|
|
@@ -217,10 +248,14 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
217
248
|
return this.variation(key, context, defaultValue);
|
|
218
249
|
}
|
|
219
250
|
/**
|
|
220
|
-
* Evaluate every flag for a context, e.g. to bootstrap a browser client
|
|
251
|
+
* Evaluate every client-side flag for a context, e.g. to bootstrap a browser client
|
|
221
252
|
* during server rendering. Doesn't record analytics.
|
|
253
|
+
*
|
|
254
|
+
* const state = await client.allFlagsState(context);
|
|
255
|
+
* createClient(id, context, { baseUrl, bootstrap: state.toJSON() });
|
|
222
256
|
*/
|
|
223
257
|
async allFlagsState(context, opts = {}) {
|
|
258
|
+
const clientSideOnly = opts.clientSideOnly ?? true;
|
|
224
259
|
let ctx;
|
|
225
260
|
try {
|
|
226
261
|
ctx = parseContext(context);
|
|
@@ -231,10 +266,19 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
231
266
|
const evaluator = new Evaluator(this.store);
|
|
232
267
|
const state = {};
|
|
233
268
|
for (const f of this.store.flags.values()) {
|
|
234
|
-
if (
|
|
269
|
+
if (f.deleted || (clientSideOnly && !f.clientSide))
|
|
235
270
|
continue;
|
|
236
271
|
const { detail } = evaluator.evaluate(f, ctx);
|
|
237
|
-
|
|
272
|
+
// Same shape as the DevPulse client endpoint, so client-side analytics and experiments keep working.
|
|
273
|
+
const inExperiment = "inExperiment" in detail.reason && !!detail.reason.inExperiment;
|
|
274
|
+
state[f.key] = {
|
|
275
|
+
value: detail.value,
|
|
276
|
+
variation: detail.variationIndex,
|
|
277
|
+
version: f.version,
|
|
278
|
+
...(opts.withReasons || inExperiment ? { reason: detail.reason } : {}),
|
|
279
|
+
...(f.trackEvents || inExperiment ? { trackEvents: true } : {}),
|
|
280
|
+
...(inExperiment ? { trackReason: true } : {}),
|
|
281
|
+
};
|
|
238
282
|
}
|
|
239
283
|
return { valid: this.initialized, values: Object.fromEntries(Object.entries(state).map(([k, v]) => [k, v.value])), toJSON: () => state };
|
|
240
284
|
}
|
package/dist/flags/react.d.ts
CHANGED
|
@@ -10,9 +10,11 @@ export interface DevPulseProviderProps {
|
|
|
10
10
|
children: ReactNode;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming)
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming) and during the
|
|
14
|
+
* first client render, an offline client serves `options.bootstrap` and makes no requests, so
|
|
15
|
+
* hydration matches the server HTML. The live client is created (and closed) in an effect,
|
|
16
|
+
* which keeps StrictMode's simulated unmount from leaving a closed client behind. Changing
|
|
17
|
+
* `context` calls identify().
|
|
16
18
|
*/
|
|
17
19
|
export declare function DevPulseProvider({ clientSideId, context, options, client: external, children }: DevPulseProviderProps): import("react").JSX.Element;
|
|
18
20
|
export declare function useDevPulseClient(): DevPulseClient;
|
|
@@ -23,5 +25,5 @@ export declare function useFlagDetail<T extends FlagValue>(key: string, defaultV
|
|
|
23
25
|
export declare function useFlags(): Record<string, FlagValue>;
|
|
24
26
|
/** True once flags have loaded (always true when bootstrapped). */
|
|
25
27
|
export declare function useFlagsReady(): boolean;
|
|
26
|
-
/** Track a custom event for metrics and experiments. */
|
|
28
|
+
/** Track a custom event for metrics and experiments. Stable across renders (safe in effect deps). */
|
|
27
29
|
export declare function useTrack(): (key: string, data?: unknown, metricValue?: number) => void;
|