@shelbyb/daywalker-sdk 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -2
- package/dist/analytics/index.d.ts +108 -0
- package/dist/analytics/index.js +371 -0
- package/dist/cli.js +5 -0
- package/dist/client.d.ts +15 -1
- package/dist/client.js +59 -12
- package/dist/config.d.ts +6 -0
- package/dist/config.js +22 -0
- package/dist/crypto/signatures.d.ts +18 -5
- package/dist/crypto/signatures.js +100 -41
- package/dist/flags/client.d.ts +102 -0
- package/dist/flags/client.js +302 -0
- package/dist/flags/common/events.d.ts +59 -0
- package/dist/flags/common/events.js +204 -0
- package/dist/flags/common/index.d.ts +5 -0
- package/dist/flags/common/index.js +12 -0
- package/dist/flags/common/sse.d.ts +62 -0
- package/dist/flags/common/sse.js +183 -0
- package/dist/flags/engine/bucket.d.ts +21 -0
- package/dist/flags/engine/bucket.js +41 -0
- package/dist/flags/engine/context.d.ts +30 -0
- package/dist/flags/engine/context.js +81 -0
- package/dist/flags/engine/evaluate.d.ts +54 -0
- package/dist/flags/engine/evaluate.js +177 -0
- package/dist/flags/engine/index.d.ts +7 -0
- package/dist/flags/engine/index.js +7 -0
- package/dist/flags/engine/operators.d.ts +13 -0
- package/dist/flags/engine/operators.js +139 -0
- package/dist/flags/engine/semver.d.ts +13 -0
- package/dist/flags/engine/semver.js +35 -0
- package/dist/flags/engine/sha1.d.ts +6 -0
- package/dist/flags/engine/sha1.js +43 -0
- package/dist/flags/engine/types.d.ts +120 -0
- package/dist/flags/engine/types.js +17 -0
- package/dist/flags/node.d.ts +97 -0
- package/dist/flags/node.js +263 -0
- package/dist/flags/react.d.ts +27 -0
- package/dist/flags/react.js +62 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/next.d.ts +15 -2
- package/dist/next.js +41 -9
- package/dist/openfeature/server.d.ts +26 -0
- package/dist/openfeature/server.js +42 -0
- package/dist/openfeature/shared.d.ts +19 -0
- package/dist/openfeature/shared.js +40 -0
- package/dist/openfeature/web.d.ts +29 -0
- package/dist/openfeature/web.js +50 -0
- package/dist/types.d.ts +3 -0
- package/docs/analytics.md +32 -0
- package/docs/flags-node.md +49 -0
- package/docs/flags-react.md +35 -0
- package/docs/flags.md +53 -0
- package/docs/openfeature.md +30 -0
- package/package.json +62 -7
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** Incremental parser: feed it chunks of text, get dispatched events back. */
|
|
2
|
+
export class SseParser {
|
|
3
|
+
buffer = "";
|
|
4
|
+
data = [];
|
|
5
|
+
eventType = "";
|
|
6
|
+
sawFirstChunk = false;
|
|
7
|
+
/** The previous chunk ended in CR, so an LF starting this chunk completes a CRLF. */
|
|
8
|
+
pendingCR = false;
|
|
9
|
+
lastEventId = "";
|
|
10
|
+
retry = null;
|
|
11
|
+
feed(chunk) {
|
|
12
|
+
if (!this.sawFirstChunk) {
|
|
13
|
+
this.sawFirstChunk = true;
|
|
14
|
+
if (chunk.charCodeAt(0) === 0xfeff)
|
|
15
|
+
chunk = chunk.slice(1); // strip BOM
|
|
16
|
+
}
|
|
17
|
+
if (this.pendingCR && chunk.startsWith("\n"))
|
|
18
|
+
chunk = chunk.slice(1);
|
|
19
|
+
this.pendingCR = false;
|
|
20
|
+
this.buffer += chunk;
|
|
21
|
+
const out = [];
|
|
22
|
+
// Lines end with CRLF, LF or CR. A CR ends the line immediately (so events
|
|
23
|
+
// dispatch without waiting for more bytes); an LF right after it is skipped.
|
|
24
|
+
let start = 0;
|
|
25
|
+
for (let i = 0; i < this.buffer.length; i++) {
|
|
26
|
+
const c = this.buffer[i];
|
|
27
|
+
if (c !== "\n" && c !== "\r")
|
|
28
|
+
continue;
|
|
29
|
+
const line = this.buffer.slice(start, i);
|
|
30
|
+
if (c === "\r") {
|
|
31
|
+
if (i === this.buffer.length - 1)
|
|
32
|
+
this.pendingCR = true;
|
|
33
|
+
else if (this.buffer[i + 1] === "\n")
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
start = i + 1;
|
|
37
|
+
const ev = this.line(line);
|
|
38
|
+
if (ev)
|
|
39
|
+
out.push(ev);
|
|
40
|
+
}
|
|
41
|
+
this.buffer = this.buffer.slice(start);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
line(line) {
|
|
45
|
+
if (line === "") {
|
|
46
|
+
if (!this.data.length) {
|
|
47
|
+
this.eventType = "";
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const ev = { type: this.eventType || "message", data: this.data.join("\n"), lastEventId: this.lastEventId };
|
|
51
|
+
this.data = [];
|
|
52
|
+
this.eventType = "";
|
|
53
|
+
return ev;
|
|
54
|
+
}
|
|
55
|
+
if (line.startsWith(":"))
|
|
56
|
+
return null;
|
|
57
|
+
const colon = line.indexOf(":");
|
|
58
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
59
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
60
|
+
if (value.startsWith(" "))
|
|
61
|
+
value = value.slice(1);
|
|
62
|
+
switch (field) {
|
|
63
|
+
case "event":
|
|
64
|
+
this.eventType = value;
|
|
65
|
+
break;
|
|
66
|
+
case "data":
|
|
67
|
+
this.data.push(value);
|
|
68
|
+
break;
|
|
69
|
+
case "id":
|
|
70
|
+
if (!value.includes("\0"))
|
|
71
|
+
this.lastEventId = value;
|
|
72
|
+
break;
|
|
73
|
+
case "retry":
|
|
74
|
+
if (/^\d+$/.test(value))
|
|
75
|
+
this.retry = Number(value);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export class SseError extends Error {
|
|
82
|
+
status;
|
|
83
|
+
constructor(message, status) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.status = status;
|
|
86
|
+
}
|
|
87
|
+
/** 4xx (except 408/429) won't succeed on retry. */
|
|
88
|
+
get recoverable() {
|
|
89
|
+
return this.status === undefined || this.status >= 500 || this.status === 408 || this.status === 429;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Exponential backoff with full jitter, reset after a stable connection. */
|
|
93
|
+
export function backoffDelay(attempt, base, max) {
|
|
94
|
+
const exp = Math.min(max, base * 2 ** Math.min(attempt, 10));
|
|
95
|
+
return Math.round(exp / 2 + Math.random() * (exp / 2));
|
|
96
|
+
}
|
|
97
|
+
export class SseClient {
|
|
98
|
+
opts;
|
|
99
|
+
controller = null;
|
|
100
|
+
stopped = false;
|
|
101
|
+
attempt = 0;
|
|
102
|
+
parser = new SseParser();
|
|
103
|
+
timer = null;
|
|
104
|
+
watchdog = null;
|
|
105
|
+
reader = null;
|
|
106
|
+
constructor(opts) {
|
|
107
|
+
this.opts = opts;
|
|
108
|
+
}
|
|
109
|
+
start() {
|
|
110
|
+
this.stopped = false;
|
|
111
|
+
void this.connect();
|
|
112
|
+
}
|
|
113
|
+
stop() {
|
|
114
|
+
this.stopped = true;
|
|
115
|
+
if (this.timer)
|
|
116
|
+
clearTimeout(this.timer);
|
|
117
|
+
// Don't leave the read-timeout timer or a pending read keeping the process alive.
|
|
118
|
+
if (this.watchdog)
|
|
119
|
+
clearTimeout(this.watchdog);
|
|
120
|
+
this.controller?.abort();
|
|
121
|
+
void this.reader?.cancel().catch(() => { });
|
|
122
|
+
}
|
|
123
|
+
/** Drop the current connection and reconnect immediately (e.g. after identify). */
|
|
124
|
+
restart() {
|
|
125
|
+
this.controller?.abort();
|
|
126
|
+
this.attempt = 0;
|
|
127
|
+
this.parser = new SseParser();
|
|
128
|
+
if (!this.stopped)
|
|
129
|
+
void this.connect();
|
|
130
|
+
}
|
|
131
|
+
async connect() {
|
|
132
|
+
if (this.stopped)
|
|
133
|
+
return;
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
this.controller = controller;
|
|
136
|
+
const f = this.opts.fetch ?? fetch;
|
|
137
|
+
const resetWatchdog = () => {
|
|
138
|
+
if (this.watchdog)
|
|
139
|
+
clearTimeout(this.watchdog);
|
|
140
|
+
this.watchdog = setTimeout(() => controller.abort(), this.opts.readTimeoutMs ?? 5 * 60_000);
|
|
141
|
+
};
|
|
142
|
+
const connectedAt = Date.now();
|
|
143
|
+
try {
|
|
144
|
+
const url = typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
|
|
145
|
+
const headers = typeof this.opts.headers === "function" ? this.opts.headers() : (this.opts.headers ?? {});
|
|
146
|
+
resetWatchdog();
|
|
147
|
+
const res = await f(url, {
|
|
148
|
+
headers: { accept: "text/event-stream", "cache-control": "no-cache", ...(this.parser.lastEventId ? { "last-event-id": this.parser.lastEventId } : {}), ...headers },
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok || !res.body)
|
|
152
|
+
throw new SseError(`Stream request failed with ${res.status}`, res.status);
|
|
153
|
+
this.opts.onOpen?.();
|
|
154
|
+
const reader = res.body.getReader();
|
|
155
|
+
this.reader = reader;
|
|
156
|
+
const decoder = new TextDecoder();
|
|
157
|
+
for (;;) {
|
|
158
|
+
const { value, done } = await reader.read();
|
|
159
|
+
if (done)
|
|
160
|
+
break;
|
|
161
|
+
resetWatchdog();
|
|
162
|
+
for (const ev of this.parser.feed(decoder.decode(value, { stream: true })))
|
|
163
|
+
this.opts.onEvent(ev);
|
|
164
|
+
}
|
|
165
|
+
throw new SseError("Stream closed by server");
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (this.watchdog)
|
|
169
|
+
clearTimeout(this.watchdog);
|
|
170
|
+
if (this.stopped || (controller.signal.aborted && this.controller !== controller))
|
|
171
|
+
return;
|
|
172
|
+
const e = err instanceof SseError ? err : new SseError(err?.message ?? "Stream error");
|
|
173
|
+
const keepGoing = this.opts.onError?.(e);
|
|
174
|
+
if (keepGoing === false || !e.recoverable)
|
|
175
|
+
return;
|
|
176
|
+
if (Date.now() - connectedAt > 60_000)
|
|
177
|
+
this.attempt = 0; // was healthy for a while
|
|
178
|
+
const base = this.parser.retry ?? this.opts.initialRetryMs ?? 1000;
|
|
179
|
+
const delay = backoffDelay(this.attempt++, base, this.opts.maxRetryMs ?? 30_000);
|
|
180
|
+
this.timer = setTimeout(() => void this.connect(), delay);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Context } from "./context.js";
|
|
2
|
+
import type { Rollout } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic bucket in [0, 1) for a context. Compatible with LaunchDarkly:
|
|
5
|
+
* sha1("<flagKey>.<salt>.<value>") (or "<seed>.<value>" for seeded rollouts),
|
|
6
|
+
* first 15 hex digits divided by 0xFFFFFFFFFFFFFFF.
|
|
7
|
+
* Returns null when the context lacks the rollout's context kind.
|
|
8
|
+
*/
|
|
9
|
+
export declare function bucketContext(ctx: Context, key: string, salt: string, opts: {
|
|
10
|
+
contextKind?: string;
|
|
11
|
+
bucketBy?: string;
|
|
12
|
+
seed?: number;
|
|
13
|
+
}): {
|
|
14
|
+
bucket: number;
|
|
15
|
+
found: boolean;
|
|
16
|
+
};
|
|
17
|
+
/** Pick a variation from a rollout. `inExperiment` is true for tracked experiment buckets. */
|
|
18
|
+
export declare function variationForRollout(ctx: Context, flagKey: string, salt: string, rollout: Rollout): {
|
|
19
|
+
variation: number;
|
|
20
|
+
inExperiment: boolean;
|
|
21
|
+
} | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getAttribute } from "./context.js";
|
|
2
|
+
import { sha1Hex } from "./sha1.js";
|
|
3
|
+
const LONG_SCALE = 0xfffffffffffffff; // 15 hex digits
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic bucket in [0, 1) for a context. Compatible with LaunchDarkly:
|
|
6
|
+
* sha1("<flagKey>.<salt>.<value>") (or "<seed>.<value>" for seeded rollouts),
|
|
7
|
+
* first 15 hex digits divided by 0xFFFFFFFFFFFFFFF.
|
|
8
|
+
* Returns null when the context lacks the rollout's context kind.
|
|
9
|
+
*/
|
|
10
|
+
export function bucketContext(ctx, key, salt, opts) {
|
|
11
|
+
const kind = opts.contextKind ?? "user";
|
|
12
|
+
const single = ctx.kinds.get(kind);
|
|
13
|
+
if (!single)
|
|
14
|
+
return { bucket: 0, found: false };
|
|
15
|
+
const raw = getAttribute(single, opts.bucketBy ?? "key");
|
|
16
|
+
let id;
|
|
17
|
+
if (typeof raw === "string")
|
|
18
|
+
id = raw;
|
|
19
|
+
else if (typeof raw === "number" && Number.isInteger(raw))
|
|
20
|
+
id = String(raw);
|
|
21
|
+
else
|
|
22
|
+
return { bucket: 0, found: true };
|
|
23
|
+
const input = opts.seed !== undefined ? `${opts.seed}.${id}` : `${key}.${salt}.${id}`;
|
|
24
|
+
return { bucket: parseInt(sha1Hex(input).slice(0, 15), 16) / LONG_SCALE, found: true };
|
|
25
|
+
}
|
|
26
|
+
/** Pick a variation from a rollout. `inExperiment` is true for tracked experiment buckets. */
|
|
27
|
+
export function variationForRollout(ctx, flagKey, salt, rollout) {
|
|
28
|
+
if (!rollout.variations.length)
|
|
29
|
+
return null;
|
|
30
|
+
const { bucket, found } = bucketContext(ctx, flagKey, salt, rollout);
|
|
31
|
+
const isExperiment = rollout.kind === "experiment";
|
|
32
|
+
let sum = 0;
|
|
33
|
+
for (const wv of rollout.variations) {
|
|
34
|
+
sum += wv.weight / 100000;
|
|
35
|
+
if (bucket < sum)
|
|
36
|
+
return { variation: wv.variation, inExperiment: isExperiment && found && !wv.untracked };
|
|
37
|
+
}
|
|
38
|
+
// Rounding or weights < 100%: fall into the last bucket.
|
|
39
|
+
const last = rollout.variations[rollout.variations.length - 1];
|
|
40
|
+
return { variation: last.variation, inExperiment: isExperiment && found && !last.untracked };
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FlagValue } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Evaluation contexts. Either a single-kind context
|
|
4
|
+
* { kind: "user", key: "u1", email: "a@b.c" }
|
|
5
|
+
* or a multi-kind context
|
|
6
|
+
* { kind: "multi", user: { key: "u1" }, organization: { key: "acme" } }
|
|
7
|
+
* A context without `kind` is treated as a "user" context.
|
|
8
|
+
*/
|
|
9
|
+
export type ContextInput = Record<string, unknown>;
|
|
10
|
+
export interface SingleContext {
|
|
11
|
+
kind: string;
|
|
12
|
+
key: string;
|
|
13
|
+
anonymous: boolean;
|
|
14
|
+
attributes: Record<string, unknown>;
|
|
15
|
+
privateAttributes: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface Context {
|
|
18
|
+
kinds: Map<string, SingleContext>;
|
|
19
|
+
/** Stable key for the whole context: the key for single contexts, `kind:key` pairs for multi. */
|
|
20
|
+
canonicalKey: string;
|
|
21
|
+
}
|
|
22
|
+
export declare class ContextError extends Error {
|
|
23
|
+
}
|
|
24
|
+
export declare function parseContext(input: unknown): Context;
|
|
25
|
+
/**
|
|
26
|
+
* Resolve an attribute reference. Plain names are looked up literally;
|
|
27
|
+
* references starting with "/" are RFC 6901 JSON Pointers into nested objects.
|
|
28
|
+
*/
|
|
29
|
+
export declare function getAttribute(ctx: SingleContext, ref: string): unknown;
|
|
30
|
+
export declare function isFlagValue(v: unknown): v is FlagValue;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const KIND_RE = /^[a-zA-Z0-9._-]+$/;
|
|
2
|
+
export class ContextError extends Error {
|
|
3
|
+
}
|
|
4
|
+
function parseSingle(kind, raw) {
|
|
5
|
+
if (!KIND_RE.test(kind) || kind === "kind" || kind === "multi")
|
|
6
|
+
throw new ContextError(`Invalid context kind "${kind}"`);
|
|
7
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
8
|
+
throw new ContextError(`Context "${kind}" must be an object`);
|
|
9
|
+
const obj = raw;
|
|
10
|
+
if (typeof obj.key !== "string" || obj.key === "")
|
|
11
|
+
throw new ContextError(`Context "${kind}" needs a non-empty string key`);
|
|
12
|
+
const meta = (obj._meta ?? {});
|
|
13
|
+
const privateAttributes = Array.isArray(meta.privateAttributes) ? meta.privateAttributes.filter((a) => typeof a === "string") : [];
|
|
14
|
+
// No prototype: `constructor`, `toString`, `__proto__` resolve only if the context sets them.
|
|
15
|
+
const attributes = Object.create(null);
|
|
16
|
+
for (const [k, v] of Object.entries(obj))
|
|
17
|
+
if (k !== "_meta" && k !== "kind")
|
|
18
|
+
attributes[k] = v;
|
|
19
|
+
return { kind, key: obj.key, anonymous: obj.anonymous === true, attributes, privateAttributes };
|
|
20
|
+
}
|
|
21
|
+
/** Escape ":" and "%" in canonical keys, as LaunchDarkly does. */
|
|
22
|
+
const esc = (s) => s.replace(/%/g, "%25").replace(/:/g, "%3A");
|
|
23
|
+
export function parseContext(input) {
|
|
24
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
25
|
+
throw new ContextError("Context must be an object");
|
|
26
|
+
const obj = input;
|
|
27
|
+
const kinds = new Map();
|
|
28
|
+
if (obj.kind === "multi") {
|
|
29
|
+
for (const [kind, raw] of Object.entries(obj))
|
|
30
|
+
if (kind !== "kind")
|
|
31
|
+
kinds.set(kind, parseSingle(kind, raw));
|
|
32
|
+
if (kinds.size === 0)
|
|
33
|
+
throw new ContextError("A multi-kind context needs at least one kind");
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const kind = typeof obj.kind === "string" ? obj.kind : "user";
|
|
37
|
+
kinds.set(kind, parseSingle(kind, obj));
|
|
38
|
+
}
|
|
39
|
+
const canonicalKey = kinds.size === 1 && kinds.has("user")
|
|
40
|
+
? kinds.get("user").key
|
|
41
|
+
: [...kinds.values()]
|
|
42
|
+
.sort((a, b) => a.kind.localeCompare(b.kind))
|
|
43
|
+
.map((c) => `${c.kind}:${esc(c.key)}`)
|
|
44
|
+
.join(":");
|
|
45
|
+
return { kinds, canonicalKey };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve an attribute reference. Plain names are looked up literally;
|
|
49
|
+
* references starting with "/" are RFC 6901 JSON Pointers into nested objects.
|
|
50
|
+
*/
|
|
51
|
+
export function getAttribute(ctx, ref) {
|
|
52
|
+
if (ref === "kind")
|
|
53
|
+
return ctx.kind;
|
|
54
|
+
if (ref === "key")
|
|
55
|
+
return ctx.key;
|
|
56
|
+
if (!ref.startsWith("/"))
|
|
57
|
+
return Object.hasOwn(ctx.attributes, ref) ? ctx.attributes[ref] : undefined;
|
|
58
|
+
const tokens = ref
|
|
59
|
+
.slice(1)
|
|
60
|
+
.split("/")
|
|
61
|
+
.map((t) => t.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
62
|
+
let cur = ctx.attributes;
|
|
63
|
+
for (const [i, t] of tokens.entries()) {
|
|
64
|
+
if (i === 0 && t === "key")
|
|
65
|
+
return ctx.key;
|
|
66
|
+
if (cur === null || typeof cur !== "object")
|
|
67
|
+
return undefined;
|
|
68
|
+
if (Array.isArray(cur)) {
|
|
69
|
+
if (!/^(0|[1-9]\d*)$/.test(t))
|
|
70
|
+
return undefined;
|
|
71
|
+
cur = cur[Number(t)];
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
cur = Object.hasOwn(cur, t) ? cur[t] : undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return cur;
|
|
78
|
+
}
|
|
79
|
+
export function isFlagValue(v) {
|
|
80
|
+
return v === null || ["string", "number", "boolean"].includes(typeof v) || typeof v === "object";
|
|
81
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type Context } from "./context.js";
|
|
2
|
+
import type { DataStore, EvaluationDetail, EvaluationReason, FlagConfig, FlagValue, SegmentConfig } from "./types.js";
|
|
3
|
+
export interface PrerequisiteEvaluation {
|
|
4
|
+
flag: FlagConfig;
|
|
5
|
+
prerequisiteOf: string;
|
|
6
|
+
detail: EvaluationDetail;
|
|
7
|
+
}
|
|
8
|
+
export interface EvaluationResult {
|
|
9
|
+
detail: EvaluationDetail;
|
|
10
|
+
/** Prerequisite flags evaluated along the way (for analytics events). */
|
|
11
|
+
prerequisites: PrerequisiteEvaluation[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Evaluates flags against contexts. Pure and synchronous: the same inputs
|
|
15
|
+
* always produce the same output on every platform.
|
|
16
|
+
*/
|
|
17
|
+
export declare class Evaluator {
|
|
18
|
+
private readonly store;
|
|
19
|
+
constructor(store: DataStore);
|
|
20
|
+
evaluate(flag: FlagConfig, context: Context): EvaluationResult;
|
|
21
|
+
private evalFlag;
|
|
22
|
+
private serve;
|
|
23
|
+
private variation;
|
|
24
|
+
private off;
|
|
25
|
+
private clauseMatches;
|
|
26
|
+
private segmentMatches;
|
|
27
|
+
}
|
|
28
|
+
export declare function errorDetail(errorKind: Extract<EvaluationReason, {
|
|
29
|
+
kind: "ERROR";
|
|
30
|
+
}>["errorKind"], value?: FlagValue): EvaluationDetail;
|
|
31
|
+
/**
|
|
32
|
+
* Convenience: evaluate a flag by key for raw context input, returning the
|
|
33
|
+
* default value (with an ERROR reason) when anything goes wrong.
|
|
34
|
+
*/
|
|
35
|
+
export declare function evaluateFlag<T extends FlagValue>(store: DataStore, flagKey: string, contextInput: unknown, defaultValue: T): EvaluationDetail<T> & {
|
|
36
|
+
prerequisites: PrerequisiteEvaluation[];
|
|
37
|
+
};
|
|
38
|
+
/** Plain in-memory store, handy for SDKs and tests. */
|
|
39
|
+
export declare class MemoryStore implements DataStore {
|
|
40
|
+
flags: Map<string, FlagConfig>;
|
|
41
|
+
segments: Map<string, SegmentConfig>;
|
|
42
|
+
constructor(data?: {
|
|
43
|
+
flags?: Record<string, FlagConfig>;
|
|
44
|
+
segments?: Record<string, SegmentConfig>;
|
|
45
|
+
});
|
|
46
|
+
replace(data: {
|
|
47
|
+
flags?: Record<string, FlagConfig>;
|
|
48
|
+
segments?: Record<string, SegmentConfig>;
|
|
49
|
+
}): void;
|
|
50
|
+
/** Apply a versioned upsert; stale versions are ignored. Returns true when applied. */
|
|
51
|
+
upsert(kind: "flag" | "segment", item: FlagConfig | SegmentConfig): boolean;
|
|
52
|
+
getFlag(key: string): FlagConfig | undefined;
|
|
53
|
+
getSegment(key: string): SegmentConfig | undefined;
|
|
54
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { bucketContext, variationForRollout } from "./bucket.js";
|
|
2
|
+
import { ContextError, getAttribute, parseContext } from "./context.js";
|
|
3
|
+
import { applyOperator } from "./operators.js";
|
|
4
|
+
const MAX_SEGMENT_DEPTH = 10;
|
|
5
|
+
class MalformedFlag extends Error {
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Evaluates flags against contexts. Pure and synchronous: the same inputs
|
|
9
|
+
* always produce the same output on every platform.
|
|
10
|
+
*/
|
|
11
|
+
export class Evaluator {
|
|
12
|
+
store;
|
|
13
|
+
constructor(store) {
|
|
14
|
+
this.store = store;
|
|
15
|
+
}
|
|
16
|
+
evaluate(flag, context) {
|
|
17
|
+
const prerequisites = [];
|
|
18
|
+
try {
|
|
19
|
+
const detail = this.evalFlag(flag, context, prerequisites, new Set());
|
|
20
|
+
return { detail, prerequisites };
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err instanceof MalformedFlag)
|
|
24
|
+
return { detail: errorDetail("MALFORMED_FLAG"), prerequisites };
|
|
25
|
+
return { detail: errorDetail("EXCEPTION"), prerequisites };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
evalFlag(flag, ctx, events, visiting) {
|
|
29
|
+
if (!flag.on)
|
|
30
|
+
return this.off(flag, { kind: "OFF" });
|
|
31
|
+
if (visiting.has(flag.key))
|
|
32
|
+
throw new MalformedFlag(`Prerequisite cycle at ${flag.key}`);
|
|
33
|
+
visiting.add(flag.key);
|
|
34
|
+
try {
|
|
35
|
+
for (const pre of flag.prerequisites) {
|
|
36
|
+
const preFlag = this.store.getFlag(pre.key);
|
|
37
|
+
if (!preFlag || preFlag.deleted)
|
|
38
|
+
return this.off(flag, { kind: "PREREQUISITE_FAILED", prerequisiteKey: pre.key });
|
|
39
|
+
const detail = this.evalFlag(preFlag, ctx, events, visiting);
|
|
40
|
+
events.push({ flag: preFlag, prerequisiteOf: flag.key, detail });
|
|
41
|
+
if (!preFlag.on || detail.variationIndex !== pre.variation) {
|
|
42
|
+
return this.off(flag, { kind: "PREREQUISITE_FAILED", prerequisiteKey: pre.key });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
visiting.delete(flag.key);
|
|
48
|
+
}
|
|
49
|
+
for (const target of flag.targets) {
|
|
50
|
+
const single = ctx.kinds.get(target.contextKind ?? "user");
|
|
51
|
+
if (single && target.values.includes(single.key))
|
|
52
|
+
return this.variation(flag, target.variation, { kind: "TARGET_MATCH" });
|
|
53
|
+
}
|
|
54
|
+
for (const [ruleIndex, rule] of flag.rules.entries()) {
|
|
55
|
+
if (rule.clauses.every((c) => this.clauseMatches(c, ctx, 0))) {
|
|
56
|
+
return this.serve(flag, rule, ctx, (inExperiment) => ({ kind: "RULE_MATCH", ruleIndex, ruleId: rule.id, ...(inExperiment ? { inExperiment } : {}) }));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return this.serve(flag, flag.fallthrough, ctx, (inExperiment) => ({ kind: "FALLTHROUGH", ...(inExperiment ? { inExperiment } : {}) }));
|
|
60
|
+
}
|
|
61
|
+
serve(flag, vr, ctx, reason) {
|
|
62
|
+
if (vr.variation !== undefined && vr.variation !== null)
|
|
63
|
+
return this.variation(flag, vr.variation, reason(false));
|
|
64
|
+
if (vr.rollout) {
|
|
65
|
+
const picked = variationForRollout(ctx, flag.key, flag.salt, vr.rollout);
|
|
66
|
+
if (!picked)
|
|
67
|
+
throw new MalformedFlag("Empty rollout");
|
|
68
|
+
return this.variation(flag, picked.variation, reason(picked.inExperiment));
|
|
69
|
+
}
|
|
70
|
+
throw new MalformedFlag("Rule has neither variation nor rollout");
|
|
71
|
+
}
|
|
72
|
+
variation(flag, index, reason) {
|
|
73
|
+
if (!Number.isInteger(index) || index < 0 || index >= flag.variations.length)
|
|
74
|
+
throw new MalformedFlag(`Invalid variation ${index}`);
|
|
75
|
+
return { value: flag.variations[index], variationIndex: index, reason };
|
|
76
|
+
}
|
|
77
|
+
off(flag, reason) {
|
|
78
|
+
if (flag.offVariation === null || flag.offVariation === undefined)
|
|
79
|
+
return { value: null, variationIndex: null, reason };
|
|
80
|
+
return this.variation(flag, flag.offVariation, reason);
|
|
81
|
+
}
|
|
82
|
+
clauseMatches(clause, ctx, depth) {
|
|
83
|
+
if (clause.op === "segmentMatch") {
|
|
84
|
+
const matched = clause.values.some((key) => typeof key === "string" && this.segmentMatches(key, ctx, depth));
|
|
85
|
+
return clause.negate ? !matched : matched;
|
|
86
|
+
}
|
|
87
|
+
// The special "kind" attribute matches against every kind in the context.
|
|
88
|
+
if (clause.attribute === "kind") {
|
|
89
|
+
const matched = [...ctx.kinds.keys()].some((k) => clause.values.some((v) => applyOperator(clause.op, k, v)));
|
|
90
|
+
return clause.negate ? !matched : matched;
|
|
91
|
+
}
|
|
92
|
+
const single = ctx.kinds.get(clause.contextKind ?? "user");
|
|
93
|
+
if (!single)
|
|
94
|
+
return false; // missing context kind never matches, even when negated
|
|
95
|
+
const actual = getAttribute(single, clause.attribute);
|
|
96
|
+
if (actual === undefined || actual === null)
|
|
97
|
+
return false;
|
|
98
|
+
const candidates = Array.isArray(actual) ? actual : [actual];
|
|
99
|
+
const matched = candidates.some((a) => clause.values.some((v) => applyOperator(clause.op, a, v)));
|
|
100
|
+
return clause.negate ? !matched : matched;
|
|
101
|
+
}
|
|
102
|
+
segmentMatches(key, ctx, depth) {
|
|
103
|
+
if (depth >= MAX_SEGMENT_DEPTH)
|
|
104
|
+
throw new MalformedFlag("Segment nesting too deep");
|
|
105
|
+
const seg = this.store.getSegment(key);
|
|
106
|
+
if (!seg || seg.deleted)
|
|
107
|
+
return false;
|
|
108
|
+
const inList = (list) => list.some((t) => {
|
|
109
|
+
const single = ctx.kinds.get(t.contextKind ?? "user");
|
|
110
|
+
return !!single && t.values.includes(single.key);
|
|
111
|
+
});
|
|
112
|
+
if (inList(seg.included))
|
|
113
|
+
return true;
|
|
114
|
+
if (inList(seg.excluded))
|
|
115
|
+
return false;
|
|
116
|
+
for (const rule of seg.rules) {
|
|
117
|
+
if (!rule.clauses.every((c) => this.clauseMatches(c, ctx, depth + 1)))
|
|
118
|
+
continue;
|
|
119
|
+
if (rule.weight === undefined || rule.weight === null)
|
|
120
|
+
return true;
|
|
121
|
+
const { bucket } = bucketContext(ctx, seg.key, seg.salt, { contextKind: rule.rolloutContextKind, bucketBy: rule.bucketBy });
|
|
122
|
+
return bucket < rule.weight / 100000;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function errorDetail(errorKind, value = null) {
|
|
128
|
+
return { value, variationIndex: null, reason: { kind: "ERROR", errorKind } };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Convenience: evaluate a flag by key for raw context input, returning the
|
|
132
|
+
* default value (with an ERROR reason) when anything goes wrong.
|
|
133
|
+
*/
|
|
134
|
+
export function evaluateFlag(store, flagKey, contextInput, defaultValue) {
|
|
135
|
+
let ctx;
|
|
136
|
+
try {
|
|
137
|
+
ctx = parseContext(contextInput);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
return { ...errorDetail(err instanceof ContextError ? "USER_NOT_SPECIFIED" : "EXCEPTION", defaultValue), prerequisites: [] };
|
|
141
|
+
}
|
|
142
|
+
const flag = store.getFlag(flagKey);
|
|
143
|
+
if (!flag || flag.deleted)
|
|
144
|
+
return { ...errorDetail("FLAG_NOT_FOUND", defaultValue), prerequisites: [] };
|
|
145
|
+
const { detail, prerequisites } = new Evaluator(store).evaluate(flag, ctx);
|
|
146
|
+
if (detail.reason.kind === "ERROR" || detail.variationIndex === null)
|
|
147
|
+
return { ...detail, value: defaultValue, prerequisites };
|
|
148
|
+
return { ...detail, prerequisites };
|
|
149
|
+
}
|
|
150
|
+
/** Plain in-memory store, handy for SDKs and tests. */
|
|
151
|
+
export class MemoryStore {
|
|
152
|
+
flags = new Map();
|
|
153
|
+
segments = new Map();
|
|
154
|
+
constructor(data) {
|
|
155
|
+
if (data)
|
|
156
|
+
this.replace(data);
|
|
157
|
+
}
|
|
158
|
+
replace(data) {
|
|
159
|
+
this.flags = new Map(Object.entries(data.flags ?? {}));
|
|
160
|
+
this.segments = new Map(Object.entries(data.segments ?? {}));
|
|
161
|
+
}
|
|
162
|
+
/** Apply a versioned upsert; stale versions are ignored. Returns true when applied. */
|
|
163
|
+
upsert(kind, item) {
|
|
164
|
+
const map = (kind === "flag" ? this.flags : this.segments);
|
|
165
|
+
const cur = map.get(item.key);
|
|
166
|
+
if (cur && cur.version >= item.version)
|
|
167
|
+
return false;
|
|
168
|
+
map.set(item.key, item);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
getFlag(key) {
|
|
172
|
+
return this.flags.get(key);
|
|
173
|
+
}
|
|
174
|
+
getSegment(key) {
|
|
175
|
+
return this.segments.get(key);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { parseContext, getAttribute, ContextError, type Context, type ContextInput, type SingleContext } from "./context.js";
|
|
3
|
+
export { Evaluator, MemoryStore, evaluateFlag, errorDetail, type EvaluationResult, type PrerequisiteEvaluation } from "./evaluate.js";
|
|
4
|
+
export { bucketContext, variationForRollout } from "./bucket.js";
|
|
5
|
+
export { applyOperator, parseTime } from "./operators.js";
|
|
6
|
+
export { parseSemVer, compareSemVer } from "./semver.js";
|
|
7
|
+
export { sha1Hex } from "./sha1.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { parseContext, getAttribute, ContextError } from "./context.js";
|
|
3
|
+
export { Evaluator, MemoryStore, evaluateFlag, errorDetail } from "./evaluate.js";
|
|
4
|
+
export { bucketContext, variationForRollout } from "./bucket.js";
|
|
5
|
+
export { applyOperator, parseTime } from "./operators.js";
|
|
6
|
+
export { parseSemVer, compareSemVer } from "./semver.js";
|
|
7
|
+
export { sha1Hex } from "./sha1.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FlagValue, Operator } from "./types.js";
|
|
2
|
+
/** `matches` limits: longer inputs or patterns never match. */
|
|
3
|
+
export declare const MAX_MATCH_INPUT_LENGTH = 1024;
|
|
4
|
+
export declare const MAX_MATCH_PATTERN_LENGTH = 500;
|
|
5
|
+
/**
|
|
6
|
+
* Conservative ReDoS heuristic: a group that contains a quantifier or alternation and is itself
|
|
7
|
+
* repeated (`(a+)+`, `(a*)*`, `(a|aa)+`, `(?:x+y)*`, `(a+){2,}`). Character classes and escapes are skipped.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hasNestedQuantifier(pattern: string): boolean;
|
|
10
|
+
/** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
|
|
11
|
+
export declare function parseTime(v: unknown): number | null;
|
|
12
|
+
/** Compare one context value with one clause value. Type mismatches never match. */
|
|
13
|
+
export declare function applyOperator(op: Exclude<Operator, "segmentMatch">, actual: unknown, expected: FlagValue): boolean;
|