@shelbyb/daywalker-sdk 0.1.4 → 0.2.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 +23 -0
- package/dist/analytics/index.d.ts +99 -0
- package/dist/analytics/index.js +352 -0
- package/dist/flags/client.d.ts +101 -0
- package/dist/flags/client.js +293 -0
- package/dist/flags/common/events.d.ts +59 -0
- package/dist/flags/common/events.js +178 -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 +80 -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 +5 -0
- package/dist/flags/engine/operators.js +89 -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 +260 -0
- package/dist/flags/react.d.ts +27 -0
- package/dist/flags/react.js +62 -0
- 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/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 +61 -6
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { compareSemVer, parseSemVer } from "./semver.js";
|
|
2
|
+
const regexCache = new Map();
|
|
3
|
+
function regex(pattern) {
|
|
4
|
+
if (!regexCache.has(pattern)) {
|
|
5
|
+
let re = null;
|
|
6
|
+
try {
|
|
7
|
+
re = new RegExp(pattern);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
re = null;
|
|
11
|
+
}
|
|
12
|
+
if (regexCache.size > 1000)
|
|
13
|
+
regexCache.clear();
|
|
14
|
+
regexCache.set(pattern, re);
|
|
15
|
+
}
|
|
16
|
+
return regexCache.get(pattern);
|
|
17
|
+
}
|
|
18
|
+
/** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
|
|
19
|
+
export function parseTime(v) {
|
|
20
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
21
|
+
return v;
|
|
22
|
+
if (typeof v !== "string")
|
|
23
|
+
return null;
|
|
24
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/i.test(v))
|
|
25
|
+
return null;
|
|
26
|
+
const t = Date.parse(v);
|
|
27
|
+
return Number.isNaN(t) ? null : t;
|
|
28
|
+
}
|
|
29
|
+
function deepEqual(a, b) {
|
|
30
|
+
if (a === b)
|
|
31
|
+
return true;
|
|
32
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object")
|
|
33
|
+
return false;
|
|
34
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
35
|
+
return false;
|
|
36
|
+
const ka = Object.keys(a);
|
|
37
|
+
const kb = Object.keys(b);
|
|
38
|
+
return ka.length === kb.length && ka.every((k) => deepEqual(a[k], b[k]));
|
|
39
|
+
}
|
|
40
|
+
/** Compare one context value with one clause value. Type mismatches never match. */
|
|
41
|
+
export function applyOperator(op, actual, expected) {
|
|
42
|
+
switch (op) {
|
|
43
|
+
case "in":
|
|
44
|
+
return deepEqual(actual, expected);
|
|
45
|
+
case "endsWith":
|
|
46
|
+
return typeof actual === "string" && typeof expected === "string" && actual.endsWith(expected);
|
|
47
|
+
case "startsWith":
|
|
48
|
+
return typeof actual === "string" && typeof expected === "string" && actual.startsWith(expected);
|
|
49
|
+
case "contains":
|
|
50
|
+
return typeof actual === "string" && typeof expected === "string" && actual.includes(expected);
|
|
51
|
+
case "matches": {
|
|
52
|
+
if (typeof actual !== "string" || typeof expected !== "string")
|
|
53
|
+
return false;
|
|
54
|
+
return regex(expected)?.test(actual) ?? false;
|
|
55
|
+
}
|
|
56
|
+
case "lessThan":
|
|
57
|
+
case "lessThanOrEqual":
|
|
58
|
+
case "greaterThan":
|
|
59
|
+
case "greaterThanOrEqual": {
|
|
60
|
+
if (typeof actual !== "number" || typeof expected !== "number")
|
|
61
|
+
return false;
|
|
62
|
+
if (op === "lessThan")
|
|
63
|
+
return actual < expected;
|
|
64
|
+
if (op === "lessThanOrEqual")
|
|
65
|
+
return actual <= expected;
|
|
66
|
+
if (op === "greaterThan")
|
|
67
|
+
return actual > expected;
|
|
68
|
+
return actual >= expected;
|
|
69
|
+
}
|
|
70
|
+
case "before":
|
|
71
|
+
case "after": {
|
|
72
|
+
const a = parseTime(actual);
|
|
73
|
+
const e = parseTime(expected);
|
|
74
|
+
if (a === null || e === null)
|
|
75
|
+
return false;
|
|
76
|
+
return op === "before" ? a < e : a > e;
|
|
77
|
+
}
|
|
78
|
+
case "semVerEqual":
|
|
79
|
+
case "semVerLessThan":
|
|
80
|
+
case "semVerGreaterThan": {
|
|
81
|
+
const a = parseSemVer(actual);
|
|
82
|
+
const e = parseSemVer(expected);
|
|
83
|
+
if (!a || !e)
|
|
84
|
+
return false;
|
|
85
|
+
const c = compareSemVer(a, e);
|
|
86
|
+
return op === "semVerEqual" ? c === 0 : op === "semVerLessThan" ? c < 0 : c > 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Versioning 2.0.0 comparison. Like LaunchDarkly, versions missing a
|
|
3
|
+
* minor or patch component ("2" or "2.1") are accepted and padded with zeros.
|
|
4
|
+
*/
|
|
5
|
+
export interface SemVer {
|
|
6
|
+
major: number;
|
|
7
|
+
minor: number;
|
|
8
|
+
patch: number;
|
|
9
|
+
prerelease: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function parseSemVer(input: unknown): SemVer | null;
|
|
12
|
+
/** Precedence per SemVer §11. Build metadata is ignored. */
|
|
13
|
+
export declare function compareSemVer(a: SemVer, b: SemVer): number;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const RE = /^v?(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
2
|
+
export function parseSemVer(input) {
|
|
3
|
+
if (typeof input !== "string")
|
|
4
|
+
return null;
|
|
5
|
+
const m = RE.exec(input.trim());
|
|
6
|
+
if (!m)
|
|
7
|
+
return null;
|
|
8
|
+
return { major: Number(m[1]), minor: Number(m[2] ?? 0), patch: Number(m[3] ?? 0), prerelease: m[4] ? m[4].split(".") : [] };
|
|
9
|
+
}
|
|
10
|
+
/** Precedence per SemVer §11. Build metadata is ignored. */
|
|
11
|
+
export function compareSemVer(a, b) {
|
|
12
|
+
for (const k of ["major", "minor", "patch"])
|
|
13
|
+
if (a[k] !== b[k])
|
|
14
|
+
return a[k] < b[k] ? -1 : 1;
|
|
15
|
+
if (!a.prerelease.length || !b.prerelease.length)
|
|
16
|
+
return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length ? -1 : 1;
|
|
17
|
+
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
|
18
|
+
const x = a.prerelease[i];
|
|
19
|
+
const y = b.prerelease[i];
|
|
20
|
+
if (x === undefined)
|
|
21
|
+
return -1;
|
|
22
|
+
if (y === undefined)
|
|
23
|
+
return 1;
|
|
24
|
+
if (x === y)
|
|
25
|
+
continue;
|
|
26
|
+
const xn = /^\d+$/.test(x);
|
|
27
|
+
const yn = /^\d+$/.test(y);
|
|
28
|
+
if (xn && yn)
|
|
29
|
+
return Number(x) < Number(y) ? -1 : 1;
|
|
30
|
+
if (xn !== yn)
|
|
31
|
+
return xn ? -1 : 1;
|
|
32
|
+
return x < y ? -1 : 1;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous SHA-1 (FIPS 180-4) over UTF-8 strings. Used only for
|
|
3
|
+
* deterministic bucketing, never for security, and kept dependency-free so the
|
|
4
|
+
* engine runs unchanged in Node, browsers, workers and edge runtimes.
|
|
5
|
+
*/
|
|
6
|
+
export declare function sha1Hex(message: string): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous SHA-1 (FIPS 180-4) over UTF-8 strings. Used only for
|
|
3
|
+
* deterministic bucketing, never for security, and kept dependency-free so the
|
|
4
|
+
* engine runs unchanged in Node, browsers, workers and edge runtimes.
|
|
5
|
+
*/
|
|
6
|
+
export function sha1Hex(message) {
|
|
7
|
+
const bytes = new TextEncoder().encode(message);
|
|
8
|
+
const len = bytes.length;
|
|
9
|
+
const words = new Uint32Array((((len + 8) >> 6) + 1) * 16);
|
|
10
|
+
for (let i = 0; i < len; i++)
|
|
11
|
+
words[i >> 2] |= bytes[i] << (24 - (i % 4) * 8);
|
|
12
|
+
words[len >> 2] |= 0x80 << (24 - (len % 4) * 8);
|
|
13
|
+
const bitLen = len * 8;
|
|
14
|
+
words[words.length - 2] = Math.floor(bitLen / 0x100000000);
|
|
15
|
+
words[words.length - 1] = bitLen >>> 0;
|
|
16
|
+
let h0 = 0x67452301, h1 = 0xefcdab89, h2 = 0x98badcfe, h3 = 0x10325476, h4 = 0xc3d2e1f0;
|
|
17
|
+
const w = new Uint32Array(80);
|
|
18
|
+
for (let block = 0; block < words.length; block += 16) {
|
|
19
|
+
for (let t = 0; t < 16; t++)
|
|
20
|
+
w[t] = words[block + t];
|
|
21
|
+
for (let t = 16; t < 80; t++) {
|
|
22
|
+
const x = w[t - 3] ^ w[t - 8] ^ w[t - 14] ^ w[t - 16];
|
|
23
|
+
w[t] = (x << 1) | (x >>> 31);
|
|
24
|
+
}
|
|
25
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4;
|
|
26
|
+
for (let t = 0; t < 80; t++) {
|
|
27
|
+
const f = t < 20 ? (b & c) | (~b & d) : t < 40 ? b ^ c ^ d : t < 60 ? (b & c) | (b & d) | (c & d) : b ^ c ^ d;
|
|
28
|
+
const k = t < 20 ? 0x5a827999 : t < 40 ? 0x6ed9eba1 : t < 60 ? 0x8f1bbcdc : 0xca62c1d6;
|
|
29
|
+
const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[t]) >>> 0;
|
|
30
|
+
e = d;
|
|
31
|
+
d = c;
|
|
32
|
+
c = (b << 30) | (b >>> 2);
|
|
33
|
+
b = a;
|
|
34
|
+
a = temp;
|
|
35
|
+
}
|
|
36
|
+
h0 = (h0 + a) >>> 0;
|
|
37
|
+
h1 = (h1 + b) >>> 0;
|
|
38
|
+
h2 = (h2 + c) >>> 0;
|
|
39
|
+
h3 = (h3 + d) >>> 0;
|
|
40
|
+
h4 = (h4 + e) >>> 0;
|
|
41
|
+
}
|
|
42
|
+
return [h0, h1, h2, h3, h4].map((h) => (h >>> 0).toString(16).padStart(8, "0")).join("");
|
|
43
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** JSON-compatible flag values. */
|
|
2
|
+
export type FlagValue = boolean | string | number | null | FlagValue[] | {
|
|
3
|
+
[key: string]: FlagValue;
|
|
4
|
+
};
|
|
5
|
+
export declare const OPERATORS: readonly ["in", "endsWith", "startsWith", "matches", "contains", "lessThan", "lessThanOrEqual", "greaterThan", "greaterThanOrEqual", "before", "after", "semVerEqual", "semVerLessThan", "semVerGreaterThan", "segmentMatch"];
|
|
6
|
+
export type Operator = (typeof OPERATORS)[number];
|
|
7
|
+
export interface Clause {
|
|
8
|
+
/** Context kind the attribute belongs to. Defaults to "user". */
|
|
9
|
+
contextKind?: string;
|
|
10
|
+
/** Attribute name, or an RFC 6901 JSON Pointer when it starts with "/". */
|
|
11
|
+
attribute: string;
|
|
12
|
+
op: Operator;
|
|
13
|
+
values: FlagValue[];
|
|
14
|
+
negate?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface WeightedVariation {
|
|
17
|
+
variation: number;
|
|
18
|
+
/** 0..100000 (thousandths of a percent). */
|
|
19
|
+
weight: number;
|
|
20
|
+
/** Excluded from experiment analysis even inside an experiment rollout. */
|
|
21
|
+
untracked?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface Rollout {
|
|
24
|
+
kind?: "rollout" | "experiment";
|
|
25
|
+
contextKind?: string;
|
|
26
|
+
bucketBy?: string;
|
|
27
|
+
/** Experiments use a fixed seed so re-running a flag config keeps buckets stable. */
|
|
28
|
+
seed?: number;
|
|
29
|
+
variations: WeightedVariation[];
|
|
30
|
+
}
|
|
31
|
+
/** A variation index or a rollout. */
|
|
32
|
+
export interface VariationOrRollout {
|
|
33
|
+
variation?: number;
|
|
34
|
+
rollout?: Rollout;
|
|
35
|
+
}
|
|
36
|
+
export interface Rule extends VariationOrRollout {
|
|
37
|
+
id: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
clauses: Clause[];
|
|
40
|
+
trackEvents?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface Target {
|
|
43
|
+
contextKind?: string;
|
|
44
|
+
variation: number;
|
|
45
|
+
values: string[];
|
|
46
|
+
}
|
|
47
|
+
export interface Prerequisite {
|
|
48
|
+
key: string;
|
|
49
|
+
variation: number;
|
|
50
|
+
}
|
|
51
|
+
/** A flag as evaluated in one environment. */
|
|
52
|
+
export interface FlagConfig {
|
|
53
|
+
key: string;
|
|
54
|
+
version: number;
|
|
55
|
+
on: boolean;
|
|
56
|
+
salt: string;
|
|
57
|
+
variations: FlagValue[];
|
|
58
|
+
offVariation: number | null;
|
|
59
|
+
fallthrough: VariationOrRollout;
|
|
60
|
+
targets: Target[];
|
|
61
|
+
rules: Rule[];
|
|
62
|
+
prerequisites: Prerequisite[];
|
|
63
|
+
clientSide?: boolean;
|
|
64
|
+
trackEvents?: boolean;
|
|
65
|
+
trackEventsFallthrough?: boolean;
|
|
66
|
+
deleted?: boolean;
|
|
67
|
+
}
|
|
68
|
+
export interface SegmentRule {
|
|
69
|
+
id: string;
|
|
70
|
+
clauses: Clause[];
|
|
71
|
+
/** Optional: include only this fraction of matching contexts (0..100000). */
|
|
72
|
+
weight?: number;
|
|
73
|
+
rolloutContextKind?: string;
|
|
74
|
+
bucketBy?: string;
|
|
75
|
+
}
|
|
76
|
+
export interface SegmentConfig {
|
|
77
|
+
key: string;
|
|
78
|
+
version: number;
|
|
79
|
+
salt: string;
|
|
80
|
+
included: Target[] | {
|
|
81
|
+
contextKind?: string;
|
|
82
|
+
values: string[];
|
|
83
|
+
}[];
|
|
84
|
+
excluded: {
|
|
85
|
+
contextKind?: string;
|
|
86
|
+
values: string[];
|
|
87
|
+
}[];
|
|
88
|
+
rules: SegmentRule[];
|
|
89
|
+
deleted?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export type ErrorKind = "FLAG_NOT_FOUND" | "MALFORMED_FLAG" | "USER_NOT_SPECIFIED" | "WRONG_TYPE" | "CLIENT_NOT_READY" | "EXCEPTION";
|
|
92
|
+
export type EvaluationReason = {
|
|
93
|
+
kind: "OFF";
|
|
94
|
+
} | {
|
|
95
|
+
kind: "FALLTHROUGH";
|
|
96
|
+
inExperiment?: boolean;
|
|
97
|
+
} | {
|
|
98
|
+
kind: "TARGET_MATCH";
|
|
99
|
+
} | {
|
|
100
|
+
kind: "RULE_MATCH";
|
|
101
|
+
ruleIndex: number;
|
|
102
|
+
ruleId: string;
|
|
103
|
+
inExperiment?: boolean;
|
|
104
|
+
} | {
|
|
105
|
+
kind: "PREREQUISITE_FAILED";
|
|
106
|
+
prerequisiteKey: string;
|
|
107
|
+
} | {
|
|
108
|
+
kind: "ERROR";
|
|
109
|
+
errorKind: ErrorKind;
|
|
110
|
+
};
|
|
111
|
+
export interface EvaluationDetail<T = FlagValue> {
|
|
112
|
+
value: T;
|
|
113
|
+
variationIndex: number | null;
|
|
114
|
+
reason: EvaluationReason;
|
|
115
|
+
}
|
|
116
|
+
/** Read-only access to flags and segments for one environment. */
|
|
117
|
+
export interface DataStore {
|
|
118
|
+
getFlag(key: string): FlagConfig | undefined;
|
|
119
|
+
getSegment(key: string): SegmentConfig | undefined;
|
|
120
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const OPERATORS = [
|
|
2
|
+
"in",
|
|
3
|
+
"endsWith",
|
|
4
|
+
"startsWith",
|
|
5
|
+
"matches",
|
|
6
|
+
"contains",
|
|
7
|
+
"lessThan",
|
|
8
|
+
"lessThanOrEqual",
|
|
9
|
+
"greaterThan",
|
|
10
|
+
"greaterThanOrEqual",
|
|
11
|
+
"before",
|
|
12
|
+
"after",
|
|
13
|
+
"semVerEqual",
|
|
14
|
+
"semVerLessThan",
|
|
15
|
+
"semVerGreaterThan",
|
|
16
|
+
"segmentMatch",
|
|
17
|
+
];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
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
|
+
export type { ContextInput, EvaluationDetail, FlagValue, Logger };
|
|
5
|
+
export interface NodeClientOptions {
|
|
6
|
+
/** Base URL of your DevPulse deployment. */
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
/** Real-time updates over SSE (default). Set false to poll instead. */
|
|
9
|
+
stream?: boolean;
|
|
10
|
+
/** Poll interval when not streaming (and as a fallback). Minimum 30s. */
|
|
11
|
+
pollIntervalMs?: number;
|
|
12
|
+
sendEvents?: boolean;
|
|
13
|
+
flushIntervalMs?: number;
|
|
14
|
+
privateAttributes?: string[];
|
|
15
|
+
allAttributesPrivate?: boolean;
|
|
16
|
+
/** Never connect; every variation returns its default (useful in tests). */
|
|
17
|
+
offline?: boolean;
|
|
18
|
+
/** Start from fixed data (e.g. tests or a file); disables network. */
|
|
19
|
+
initialData?: {
|
|
20
|
+
flags?: Record<string, FlagConfig>;
|
|
21
|
+
segments?: Record<string, SegmentConfig>;
|
|
22
|
+
};
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
logger?: Logger;
|
|
25
|
+
}
|
|
26
|
+
export interface AllFlagsState {
|
|
27
|
+
valid: boolean;
|
|
28
|
+
values: Record<string, FlagValue>;
|
|
29
|
+
/** Pass to the browser SDK's `bootstrap` option for flicker-free SSR. */
|
|
30
|
+
toJSON(): Record<string, {
|
|
31
|
+
value: FlagValue;
|
|
32
|
+
variation: number | null;
|
|
33
|
+
version: number;
|
|
34
|
+
reason?: unknown;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Server-side client. One instance per process and SDK key.
|
|
39
|
+
*
|
|
40
|
+
* const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
|
|
41
|
+
* await client.waitForInitialization({ timeoutMs: 5000 });
|
|
42
|
+
* const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
|
|
43
|
+
*/
|
|
44
|
+
export declare class DevPulseNodeClient extends EventEmitter {
|
|
45
|
+
private readonly sdkKey;
|
|
46
|
+
private readonly options;
|
|
47
|
+
private store;
|
|
48
|
+
private stream;
|
|
49
|
+
private poller;
|
|
50
|
+
private etag;
|
|
51
|
+
private events;
|
|
52
|
+
private initialized;
|
|
53
|
+
private ready;
|
|
54
|
+
private resolveReady;
|
|
55
|
+
private rejectReady;
|
|
56
|
+
private streamFailures;
|
|
57
|
+
private readonly logger;
|
|
58
|
+
private readonly base;
|
|
59
|
+
constructor(sdkKey: string, options: NodeClientOptions);
|
|
60
|
+
private headers;
|
|
61
|
+
private startStreaming;
|
|
62
|
+
private startPolling;
|
|
63
|
+
private stopPolling;
|
|
64
|
+
private onStreamEvent;
|
|
65
|
+
private applyPut;
|
|
66
|
+
private emitUpdate;
|
|
67
|
+
private markReady;
|
|
68
|
+
private fail;
|
|
69
|
+
waitForInitialization(opts?: {
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
}): Promise<this>;
|
|
72
|
+
get isInitialized(): boolean;
|
|
73
|
+
/** Evaluate a flag locally. Never throws; returns `defaultValue` on any problem. */
|
|
74
|
+
variationDetailSync<T extends FlagValue>(key: string, context: ContextInput, defaultValue: T): EvaluationDetail<T>;
|
|
75
|
+
variationSync<T extends FlagValue>(key: string, context: ContextInput, defaultValue: T): T;
|
|
76
|
+
variation<T extends FlagValue>(key: string, context: ContextInput, defaultValue: T): Promise<T>;
|
|
77
|
+
variationDetail<T extends FlagValue>(key: string, context: ContextInput, defaultValue: T): Promise<EvaluationDetail<T>>;
|
|
78
|
+
boolVariation(key: string, context: ContextInput, defaultValue: boolean): Promise<boolean>;
|
|
79
|
+
stringVariation(key: string, context: ContextInput, defaultValue: string): Promise<string>;
|
|
80
|
+
numberVariation(key: string, context: ContextInput, defaultValue: number): Promise<number>;
|
|
81
|
+
jsonVariation<T extends FlagValue>(key: string, context: ContextInput, defaultValue: T): Promise<T>;
|
|
82
|
+
/**
|
|
83
|
+
* Evaluate every flag for a context, e.g. to bootstrap a browser client
|
|
84
|
+
* during server rendering. Doesn't record analytics.
|
|
85
|
+
*/
|
|
86
|
+
allFlagsState(context: ContextInput, opts?: {
|
|
87
|
+
clientSideOnly?: boolean;
|
|
88
|
+
withReasons?: boolean;
|
|
89
|
+
}): Promise<AllFlagsState>;
|
|
90
|
+
/** Secure mode hash for a context, for the browser SDK's `hash` option. */
|
|
91
|
+
secureModeHash(context: ContextInput): string;
|
|
92
|
+
track(key: string, context: ContextInput, data?: unknown, metricValue?: number): void;
|
|
93
|
+
identify(context: ContextInput): void;
|
|
94
|
+
flush(): Promise<void>;
|
|
95
|
+
close(): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
export declare function init(sdkKey: string, options: NodeClientOptions): DevPulseNodeClient;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { Evaluator, MemoryStore, evaluateFlag, parseContext } from "./engine/index.js";
|
|
4
|
+
import { EventProcessor, SseClient, noopLogger } from "./common/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Server-side client. One instance per process and SDK key.
|
|
7
|
+
*
|
|
8
|
+
* const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
|
|
9
|
+
* await client.waitForInitialization({ timeoutMs: 5000 });
|
|
10
|
+
* const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
|
|
11
|
+
*/
|
|
12
|
+
export class DevPulseNodeClient extends EventEmitter {
|
|
13
|
+
sdkKey;
|
|
14
|
+
options;
|
|
15
|
+
store = new MemoryStore();
|
|
16
|
+
stream = null;
|
|
17
|
+
poller = null;
|
|
18
|
+
etag = null;
|
|
19
|
+
events = null;
|
|
20
|
+
initialized = false;
|
|
21
|
+
ready;
|
|
22
|
+
resolveReady;
|
|
23
|
+
rejectReady;
|
|
24
|
+
streamFailures = 0;
|
|
25
|
+
logger;
|
|
26
|
+
base;
|
|
27
|
+
constructor(sdkKey, options) {
|
|
28
|
+
super();
|
|
29
|
+
this.sdkKey = sdkKey;
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.logger = options.logger ?? noopLogger;
|
|
32
|
+
this.base = options.baseUrl.replace(/\/$/, "");
|
|
33
|
+
this.ready = new Promise((res, rej) => {
|
|
34
|
+
this.resolveReady = res;
|
|
35
|
+
this.rejectReady = rej;
|
|
36
|
+
});
|
|
37
|
+
this.ready.catch(() => undefined);
|
|
38
|
+
if (options.initialData || options.offline) {
|
|
39
|
+
if (options.initialData)
|
|
40
|
+
this.store.replace(options.initialData);
|
|
41
|
+
this.markReady();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (options.sendEvents !== false) {
|
|
45
|
+
this.events = new EventProcessor({
|
|
46
|
+
url: `${this.base}/sdk/v1/events`,
|
|
47
|
+
headers: { authorization: sdkKey },
|
|
48
|
+
flushIntervalMs: options.flushIntervalMs ?? 5000,
|
|
49
|
+
privateAttributes: options.privateAttributes,
|
|
50
|
+
allAttributesPrivate: options.allAttributesPrivate,
|
|
51
|
+
fetch: options.fetch,
|
|
52
|
+
logger: this.logger,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (options.stream === false)
|
|
56
|
+
this.startPolling();
|
|
57
|
+
else
|
|
58
|
+
this.startStreaming();
|
|
59
|
+
}
|
|
60
|
+
headers() {
|
|
61
|
+
return { authorization: this.sdkKey, "user-agent": "DevPulseNode/0.1.0" };
|
|
62
|
+
}
|
|
63
|
+
startStreaming() {
|
|
64
|
+
this.stream = new SseClient({
|
|
65
|
+
url: `${this.base}/sdk/v1/server/stream`,
|
|
66
|
+
headers: () => this.headers(),
|
|
67
|
+
fetch: this.options.fetch,
|
|
68
|
+
onOpen: () => {
|
|
69
|
+
this.streamFailures = 0;
|
|
70
|
+
this.stopPolling();
|
|
71
|
+
},
|
|
72
|
+
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
73
|
+
onError: (err) => {
|
|
74
|
+
this.logger.warn(`DevPulse stream error: ${err.message}`);
|
|
75
|
+
if (err.status === 401 || err.status === 403) {
|
|
76
|
+
this.fail(new Error("Invalid SDK key"));
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
// After repeated failures, poll so data stays fresh while we keep retrying the stream.
|
|
80
|
+
if (++this.streamFailures >= 3)
|
|
81
|
+
this.startPolling();
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
this.stream.start();
|
|
86
|
+
}
|
|
87
|
+
startPolling() {
|
|
88
|
+
if (this.poller)
|
|
89
|
+
return;
|
|
90
|
+
const interval = Math.max(30_000, this.options.pollIntervalMs ?? 30_000);
|
|
91
|
+
const poll = async () => {
|
|
92
|
+
try {
|
|
93
|
+
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)
|
|
95
|
+
return this.markReady();
|
|
96
|
+
if (res.status === 401 || res.status === 403)
|
|
97
|
+
return this.fail(new Error("Invalid SDK key"));
|
|
98
|
+
if (!res.ok)
|
|
99
|
+
throw new Error(`Poll failed with ${res.status}`);
|
|
100
|
+
this.etag = res.headers.get("etag");
|
|
101
|
+
this.applyPut((await res.json()));
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
this.logger.warn("DevPulse poll failed", err);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
this.poller = setInterval(poll, interval);
|
|
108
|
+
this.poller.unref?.();
|
|
109
|
+
void poll();
|
|
110
|
+
}
|
|
111
|
+
stopPolling() {
|
|
112
|
+
if (this.poller)
|
|
113
|
+
clearInterval(this.poller);
|
|
114
|
+
this.poller = null;
|
|
115
|
+
}
|
|
116
|
+
onStreamEvent(type, data) {
|
|
117
|
+
try {
|
|
118
|
+
const payload = JSON.parse(data);
|
|
119
|
+
if (type === "put")
|
|
120
|
+
this.applyPut(payload.data);
|
|
121
|
+
else if (type === "patch" || type === "delete") {
|
|
122
|
+
const [, kind, key] = String(payload.path).split("/");
|
|
123
|
+
const isFlag = kind === "flags";
|
|
124
|
+
const prev = isFlag ? this.store.getFlag(key) : this.store.getSegment(key);
|
|
125
|
+
if (type === "patch")
|
|
126
|
+
this.store.upsert(isFlag ? "flag" : "segment", payload.data);
|
|
127
|
+
else if (!prev || prev.version < payload.version)
|
|
128
|
+
(isFlag ? this.store.flags : this.store.segments).delete(key);
|
|
129
|
+
if (isFlag)
|
|
130
|
+
this.emitUpdate(key);
|
|
131
|
+
else
|
|
132
|
+
for (const f of this.store.flags.values())
|
|
133
|
+
if (JSON.stringify(f.rules).includes(`"${key}"`))
|
|
134
|
+
this.emitUpdate(f.key);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
this.logger.error("Malformed stream event", err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
applyPut(data) {
|
|
142
|
+
const before = new Map([...this.store.flags.entries()].map(([k, f]) => [k, f.version]));
|
|
143
|
+
this.store.replace(data);
|
|
144
|
+
for (const [k, f] of this.store.flags)
|
|
145
|
+
if (before.get(k) !== f.version)
|
|
146
|
+
this.emitUpdate(k);
|
|
147
|
+
for (const k of before.keys())
|
|
148
|
+
if (!this.store.flags.has(k))
|
|
149
|
+
this.emitUpdate(k);
|
|
150
|
+
this.markReady();
|
|
151
|
+
}
|
|
152
|
+
emitUpdate(key) {
|
|
153
|
+
this.emit("update", { key });
|
|
154
|
+
this.emit(`update:${key}`, { key });
|
|
155
|
+
}
|
|
156
|
+
markReady() {
|
|
157
|
+
if (this.initialized)
|
|
158
|
+
return;
|
|
159
|
+
this.initialized = true;
|
|
160
|
+
this.resolveReady();
|
|
161
|
+
this.emit("ready");
|
|
162
|
+
}
|
|
163
|
+
fail(err) {
|
|
164
|
+
if (!this.initialized)
|
|
165
|
+
this.rejectReady(err);
|
|
166
|
+
this.emit("failed", err);
|
|
167
|
+
}
|
|
168
|
+
// ---------- Public API ----------
|
|
169
|
+
waitForInitialization(opts = {}) {
|
|
170
|
+
const ready = this.ready.then(() => this);
|
|
171
|
+
if (!opts.timeoutMs)
|
|
172
|
+
return ready;
|
|
173
|
+
return Promise.race([ready, new Promise((_, rej) => setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs).unref?.())]);
|
|
174
|
+
}
|
|
175
|
+
get isInitialized() {
|
|
176
|
+
return this.initialized;
|
|
177
|
+
}
|
|
178
|
+
/** Evaluate a flag locally. Never throws; returns `defaultValue` on any problem. */
|
|
179
|
+
variationDetailSync(key, context, defaultValue) {
|
|
180
|
+
if (!this.initialized)
|
|
181
|
+
return { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "CLIENT_NOT_READY" } };
|
|
182
|
+
const { prerequisites, ...detail } = evaluateFlag(this.store, key, context, defaultValue);
|
|
183
|
+
const flag = this.store.getFlag(key);
|
|
184
|
+
let out = detail;
|
|
185
|
+
if (detail.reason.kind !== "ERROR" && defaultValue !== null && typeof defaultValue !== typeof detail.value) {
|
|
186
|
+
out = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "WRONG_TYPE" } };
|
|
187
|
+
}
|
|
188
|
+
if (this.events) {
|
|
189
|
+
for (const p of prerequisites)
|
|
190
|
+
this.events.recordEvaluation({ key: p.flag.key, context, value: p.detail.value, default: null, variation: p.detail.variationIndex, version: p.flag.version, reason: p.detail.reason, trackEvents: p.flag.trackEvents });
|
|
191
|
+
this.events.recordEvaluation({ key, context, value: out.value, default: defaultValue, variation: out.variationIndex, version: flag?.version, reason: out.reason, trackEvents: flag?.trackEvents });
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
variationSync(key, context, defaultValue) {
|
|
196
|
+
return this.variationDetailSync(key, context, defaultValue).value;
|
|
197
|
+
}
|
|
198
|
+
async variation(key, context, defaultValue) {
|
|
199
|
+
return this.variationSync(key, context, defaultValue);
|
|
200
|
+
}
|
|
201
|
+
async variationDetail(key, context, defaultValue) {
|
|
202
|
+
return this.variationDetailSync(key, context, defaultValue);
|
|
203
|
+
}
|
|
204
|
+
async boolVariation(key, context, defaultValue) {
|
|
205
|
+
return this.variation(key, context, defaultValue);
|
|
206
|
+
}
|
|
207
|
+
async stringVariation(key, context, defaultValue) {
|
|
208
|
+
return this.variation(key, context, defaultValue);
|
|
209
|
+
}
|
|
210
|
+
async numberVariation(key, context, defaultValue) {
|
|
211
|
+
return this.variation(key, context, defaultValue);
|
|
212
|
+
}
|
|
213
|
+
async jsonVariation(key, context, defaultValue) {
|
|
214
|
+
return this.variation(key, context, defaultValue);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Evaluate every flag for a context, e.g. to bootstrap a browser client
|
|
218
|
+
* during server rendering. Doesn't record analytics.
|
|
219
|
+
*/
|
|
220
|
+
async allFlagsState(context, opts = {}) {
|
|
221
|
+
let ctx;
|
|
222
|
+
try {
|
|
223
|
+
ctx = parseContext(context);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return { valid: false, values: {}, toJSON: () => ({}) };
|
|
227
|
+
}
|
|
228
|
+
const evaluator = new Evaluator(this.store);
|
|
229
|
+
const state = {};
|
|
230
|
+
for (const f of this.store.flags.values()) {
|
|
231
|
+
if (opts.clientSideOnly && !f.clientSide)
|
|
232
|
+
continue;
|
|
233
|
+
const { detail } = evaluator.evaluate(f, ctx);
|
|
234
|
+
state[f.key] = { value: detail.value, variation: detail.variationIndex, version: f.version, ...(opts.withReasons ? { reason: detail.reason } : {}) };
|
|
235
|
+
}
|
|
236
|
+
return { valid: this.initialized, values: Object.fromEntries(Object.entries(state).map(([k, v]) => [k, v.value])), toJSON: () => state };
|
|
237
|
+
}
|
|
238
|
+
/** Secure mode hash for a context, for the browser SDK's `hash` option. */
|
|
239
|
+
secureModeHash(context) {
|
|
240
|
+
return createHmac("sha256", this.sdkKey).update(parseContext(context).canonicalKey).digest("hex");
|
|
241
|
+
}
|
|
242
|
+
track(key, context, data, metricValue) {
|
|
243
|
+
this.events?.track(key, context, data, metricValue);
|
|
244
|
+
}
|
|
245
|
+
identify(context) {
|
|
246
|
+
this.events?.identify(context);
|
|
247
|
+
}
|
|
248
|
+
async flush() {
|
|
249
|
+
await this.events?.flush();
|
|
250
|
+
}
|
|
251
|
+
async close() {
|
|
252
|
+
this.stream?.stop();
|
|
253
|
+
this.stopPolling();
|
|
254
|
+
await this.events?.close();
|
|
255
|
+
this.removeAllListeners();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export function init(sdkKey, options) {
|
|
259
|
+
return new DevPulseNodeClient(sdkKey, options);
|
|
260
|
+
}
|