@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/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
|
}
|
|
@@ -11,7 +11,8 @@ function parseSingle(kind, raw) {
|
|
|
11
11
|
throw new ContextError(`Context "${kind}" needs a non-empty string key`);
|
|
12
12
|
const meta = (obj._meta ?? {});
|
|
13
13
|
const privateAttributes = Array.isArray(meta.privateAttributes) ? meta.privateAttributes.filter((a) => typeof a === "string") : [];
|
|
14
|
-
|
|
14
|
+
// No prototype: `constructor`, `toString`, `__proto__` resolve only if the context sets them.
|
|
15
|
+
const attributes = Object.create(null);
|
|
15
16
|
for (const [k, v] of Object.entries(obj))
|
|
16
17
|
if (k !== "_meta" && k !== "kind")
|
|
17
18
|
attributes[k] = v;
|
|
@@ -53,7 +54,7 @@ export function getAttribute(ctx, ref) {
|
|
|
53
54
|
if (ref === "key")
|
|
54
55
|
return ctx.key;
|
|
55
56
|
if (!ref.startsWith("/"))
|
|
56
|
-
return ctx.attributes[ref];
|
|
57
|
+
return Object.hasOwn(ctx.attributes, ref) ? ctx.attributes[ref] : undefined;
|
|
57
58
|
const tokens = ref
|
|
58
59
|
.slice(1)
|
|
59
60
|
.split("/")
|
|
@@ -70,7 +71,7 @@ export function getAttribute(ctx, ref) {
|
|
|
70
71
|
cur = cur[Number(t)];
|
|
71
72
|
}
|
|
72
73
|
else {
|
|
73
|
-
cur = cur[t];
|
|
74
|
+
cur = Object.hasOwn(cur, t) ? cur[t] : undefined;
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
return cur;
|
|
@@ -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,4 +1,29 @@
|
|
|
1
1
|
import type { FlagValue, Operator } from "./types.js";
|
|
2
|
+
/** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
|
|
3
|
+
export declare const MAX_MATCH_INPUT_LENGTH = 1024;
|
|
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
|
+
};
|
|
12
|
+
/**
|
|
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,}`).
|
|
25
|
+
*/
|
|
26
|
+
export declare function hasNestedQuantifier(pattern: string): boolean;
|
|
2
27
|
/** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
|
|
3
28
|
export declare function parseTime(v: unknown): number | null;
|
|
4
29
|
/** Compare one context value with one clause value. Type mismatches never match. */
|
|
@@ -1,13 +1,28 @@
|
|
|
1
|
+
import { RE2JS } from "re2js";
|
|
1
2
|
import { compareSemVer, parseSemVer } from "./semver.js";
|
|
3
|
+
/** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
|
|
4
|
+
export const MAX_MATCH_INPUT_LENGTH = 1024;
|
|
5
|
+
export const MAX_MATCH_PATTERN_LENGTH = 500;
|
|
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;
|
|
2
9
|
const regexCache = new Map();
|
|
3
|
-
|
|
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) {
|
|
4
17
|
if (!regexCache.has(pattern)) {
|
|
5
18
|
let re = null;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
19
|
+
if (pattern.length <= MAX_MATCH_PATTERN_LENGTH) {
|
|
20
|
+
try {
|
|
21
|
+
re = RE2JS.compile(pattern);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
re = null;
|
|
25
|
+
}
|
|
11
26
|
}
|
|
12
27
|
if (regexCache.size > 1000)
|
|
13
28
|
regexCache.clear();
|
|
@@ -15,6 +30,47 @@ function regex(pattern) {
|
|
|
15
30
|
}
|
|
16
31
|
return regexCache.get(pattern);
|
|
17
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
|
+
}
|
|
37
|
+
/**
|
|
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,}`).
|
|
41
|
+
*/
|
|
42
|
+
export function hasNestedQuantifier(pattern) {
|
|
43
|
+
const stack = []; // per open group: contains a quantifier / alternation
|
|
44
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
45
|
+
const ch = pattern[i];
|
|
46
|
+
if (ch === "\\") {
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
else if (ch === "[") {
|
|
50
|
+
for (i++; i < pattern.length && pattern[i] !== "]"; i++)
|
|
51
|
+
if (pattern[i] === "\\")
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
else if (ch === "(") {
|
|
55
|
+
stack.push(false);
|
|
56
|
+
if (pattern[i + 1] === "?")
|
|
57
|
+
i++; // group modifier, not a quantifier
|
|
58
|
+
}
|
|
59
|
+
else if (ch === ")") {
|
|
60
|
+
const risky = stack.pop() ?? false;
|
|
61
|
+
const next = pattern[i + 1];
|
|
62
|
+
const repeated = next === "*" || next === "+" || next === "{";
|
|
63
|
+
if (risky && repeated)
|
|
64
|
+
return true;
|
|
65
|
+
if ((risky || repeated) && stack.length)
|
|
66
|
+
stack[stack.length - 1] = true;
|
|
67
|
+
}
|
|
68
|
+
else if ((ch === "*" || ch === "+" || ch === "?" || ch === "{" || ch === "|") && stack.length) {
|
|
69
|
+
stack[stack.length - 1] = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
18
74
|
/** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
|
|
19
75
|
export function parseTime(v) {
|
|
20
76
|
if (typeof v === "number" && Number.isFinite(v))
|
|
@@ -49,9 +105,9 @@ export function applyOperator(op, actual, expected) {
|
|
|
49
105
|
case "contains":
|
|
50
106
|
return typeof actual === "string" && typeof expected === "string" && actual.includes(expected);
|
|
51
107
|
case "matches": {
|
|
52
|
-
if (typeof actual !== "string" || typeof expected !== "string")
|
|
108
|
+
if (typeof actual !== "string" || typeof expected !== "string" || actual.length > MAX_MATCH_INPUT_LENGTH)
|
|
53
109
|
return false;
|
|
54
|
-
return
|
|
110
|
+
return compilePattern(expected)?.test(actual) ?? false;
|
|
55
111
|
}
|
|
56
112
|
case "lessThan":
|
|
57
113
|
case "lessThanOrEqual":
|
package/dist/flags/node.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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
|
-
/** Base URL of your DevPulse deployment. */
|
|
8
|
+
/** Base URL of your DevPulse deployment (https required except on localhost). */
|
|
7
9
|
baseUrl: string;
|
|
8
10
|
/** Real-time updates over SSE (default). Set false to poll instead. */
|
|
9
11
|
stream?: boolean;
|
|
@@ -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,10 +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
|
+
import { assertSecureUrl } from "../config.js";
|
|
6
|
+
export { SDK_VERSION };
|
|
5
7
|
/**
|
|
6
8
|
* Server-side client. One instance per process and SDK key.
|
|
7
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
|
+
*
|
|
8
13
|
* const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
|
|
9
14
|
* await client.waitForInitialization({ timeoutMs: 5000 });
|
|
10
15
|
* const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
|
|
@@ -22,6 +27,7 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
22
27
|
resolveReady;
|
|
23
28
|
rejectReady;
|
|
24
29
|
streamFailures = 0;
|
|
30
|
+
stale = false;
|
|
25
31
|
logger;
|
|
26
32
|
base;
|
|
27
33
|
constructor(sdkKey, options) {
|
|
@@ -41,6 +47,8 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
41
47
|
this.markReady();
|
|
42
48
|
return;
|
|
43
49
|
}
|
|
50
|
+
// Checked once the SDK key will actually be sent (offline / fixture clients make no requests).
|
|
51
|
+
assertSecureUrl("baseUrl", options.baseUrl);
|
|
44
52
|
if (options.sendEvents !== false) {
|
|
45
53
|
this.events = new EventProcessor({
|
|
46
54
|
url: `${this.base}/sdk/v1/events`,
|
|
@@ -58,16 +66,20 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
58
66
|
this.startStreaming();
|
|
59
67
|
}
|
|
60
68
|
headers() {
|
|
61
|
-
return { authorization: this.sdkKey, "user-agent":
|
|
69
|
+
return { authorization: this.sdkKey, "user-agent": `DevPulseNode/${SDK_VERSION}` };
|
|
62
70
|
}
|
|
63
71
|
startStreaming() {
|
|
64
72
|
this.stream = new SseClient({
|
|
65
73
|
url: `${this.base}/sdk/v1/server/stream`,
|
|
66
74
|
headers: () => this.headers(),
|
|
67
75
|
fetch: this.options.fetch,
|
|
76
|
+
browser: false,
|
|
77
|
+
// Full rulesets (segments with big target lists) can be large.
|
|
78
|
+
maxEventSize: 64 * 1024 * 1024,
|
|
68
79
|
onOpen: () => {
|
|
69
80
|
this.streamFailures = 0;
|
|
70
81
|
this.stopPolling();
|
|
82
|
+
this.setStale(false);
|
|
71
83
|
},
|
|
72
84
|
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
73
85
|
onError: (err) => {
|
|
@@ -76,6 +88,9 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
76
88
|
this.fail(new Error("Invalid SDK key"));
|
|
77
89
|
return false;
|
|
78
90
|
}
|
|
91
|
+
// While polling, the poll result decides whether data is stale.
|
|
92
|
+
if (!this.poller)
|
|
93
|
+
this.setStale(true, err);
|
|
79
94
|
// After repeated failures, poll so data stays fresh while we keep retrying the stream.
|
|
80
95
|
if (++this.streamFailures >= 3)
|
|
81
96
|
this.startPolling();
|
|
@@ -91,17 +106,21 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
91
106
|
const poll = async () => {
|
|
92
107
|
try {
|
|
93
108
|
const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/server/flags`, { headers: { ...this.headers(), ...(this.etag ? { "if-none-match": this.etag } : {}) } });
|
|
94
|
-
if (res.status === 304)
|
|
109
|
+
if (res.status === 304) {
|
|
110
|
+
this.setStale(false);
|
|
95
111
|
return this.markReady();
|
|
112
|
+
}
|
|
96
113
|
if (res.status === 401 || res.status === 403)
|
|
97
114
|
return this.fail(new Error("Invalid SDK key"));
|
|
98
115
|
if (!res.ok)
|
|
99
116
|
throw new Error(`Poll failed with ${res.status}`);
|
|
100
117
|
this.etag = res.headers.get("etag");
|
|
101
118
|
this.applyPut((await res.json()));
|
|
119
|
+
this.setStale(false);
|
|
102
120
|
}
|
|
103
121
|
catch (err) {
|
|
104
122
|
this.logger.warn("DevPulse poll failed", err);
|
|
123
|
+
this.setStale(true, err);
|
|
105
124
|
}
|
|
106
125
|
};
|
|
107
126
|
this.poller = setInterval(poll, interval);
|
|
@@ -160,6 +179,16 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
160
179
|
this.resolveReady();
|
|
161
180
|
this.emit("ready");
|
|
162
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
|
+
}
|
|
163
192
|
fail(err) {
|
|
164
193
|
if (!this.initialized)
|
|
165
194
|
this.rejectReady(err);
|
|
@@ -170,7 +199,12 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
170
199
|
const ready = this.ready.then(() => this);
|
|
171
200
|
if (!opts.timeoutMs)
|
|
172
201
|
return ready;
|
|
173
|
-
|
|
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));
|
|
174
208
|
}
|
|
175
209
|
get isInitialized() {
|
|
176
210
|
return this.initialized;
|
|
@@ -214,10 +248,14 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
214
248
|
return this.variation(key, context, defaultValue);
|
|
215
249
|
}
|
|
216
250
|
/**
|
|
217
|
-
* 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
|
|
218
252
|
* during server rendering. Doesn't record analytics.
|
|
253
|
+
*
|
|
254
|
+
* const state = await client.allFlagsState(context);
|
|
255
|
+
* createClient(id, context, { baseUrl, bootstrap: state.toJSON() });
|
|
219
256
|
*/
|
|
220
257
|
async allFlagsState(context, opts = {}) {
|
|
258
|
+
const clientSideOnly = opts.clientSideOnly ?? true;
|
|
221
259
|
let ctx;
|
|
222
260
|
try {
|
|
223
261
|
ctx = parseContext(context);
|
|
@@ -228,10 +266,19 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
228
266
|
const evaluator = new Evaluator(this.store);
|
|
229
267
|
const state = {};
|
|
230
268
|
for (const f of this.store.flags.values()) {
|
|
231
|
-
if (
|
|
269
|
+
if (f.deleted || (clientSideOnly && !f.clientSide))
|
|
232
270
|
continue;
|
|
233
271
|
const { detail } = evaluator.evaluate(f, ctx);
|
|
234
|
-
|
|
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
|
+
};
|
|
235
282
|
}
|
|
236
283
|
return { valid: this.initialized, values: Object.fromEntries(Object.entries(state).map(([k, v]) => [k, v.value])), toJSON: () => state };
|
|
237
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;
|