@flagbearer/node 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.md +147 -2
- package/dist/index.d.ts +17 -0
- package/dist/index.js +738 -0
- package/dist/stream.d.ts +12 -0
- package/package.json +44 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Flagbearer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @flagbearer/node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node.js SDK for Flagbearer. Downloads an environment's ruleset and evaluates flags
|
|
4
|
+
locally for each user, without making a network request for every flag check.
|
|
5
|
+
Requires Node.js 22 or later.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @flagbearer/node
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Keys
|
|
14
|
+
|
|
15
|
+
Use a server key, which starts with `fbs_`. Server keys are secret, so load them
|
|
16
|
+
from your environment and never include them in browser code. Browsers use client
|
|
17
|
+
keys, which start with `fbc_`, through `@flagbearer/web`.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { init } from "@flagbearer/node";
|
|
23
|
+
|
|
24
|
+
const flags = init({
|
|
25
|
+
key: process.env.FLAGBEARER_SERVER_KEY!,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await flags.sync(); // Optional: wait for fresh rules before accepting requests.
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error("Initial Flagbearer sync failed", error);
|
|
32
|
+
// Continue using defaults; the SDK retries transient failures in the background.
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const enabled = flags.isEnabled(
|
|
36
|
+
"advanced-search",
|
|
37
|
+
{ key: "123", email: "jane@example.com" },
|
|
38
|
+
false, // Application default when the rule is unknown or can't be evaluated.
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Keep one client per process, pass each request's user explicitly, and close it
|
|
42
|
+
// during application shutdown rather than after each request.
|
|
43
|
+
flags.close();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`url` is optional and defaults to `https://sdk.flagbearer.dev`, the SDK API
|
|
47
|
+
origin. Override it with the SDK URL printed by `bun run dev` (by default
|
|
48
|
+
`http://127.0.0.1:8081`) for local development, or with a self-hosted
|
|
49
|
+
deployment's SDK origin; use HTTPS for remote production servers. Secret server keys are sent in
|
|
50
|
+
`Authorization: Bearer ...` headers for both HTTP requests and streaming, never
|
|
51
|
+
in URLs. Do not include server keys in browser code. Requests reject redirects
|
|
52
|
+
rather than following a changed API location.
|
|
53
|
+
|
|
54
|
+
## Refreshing and outages
|
|
55
|
+
|
|
56
|
+
Pass `debug: true` to `init` to log stream connections, received versions, refresh
|
|
57
|
+
attempts, retries, and applied snapshot versions using `console.debug`. Debugging
|
|
58
|
+
is off by default; existing error logs are independent of this option. Logs never
|
|
59
|
+
include SDK keys, user identities, flag names, or ruleset contents.
|
|
60
|
+
|
|
61
|
+
For a change delivered over SSE, look for both `Stream version received` and
|
|
62
|
+
`Snapshot applied` with the same version. A notification alone doesn't mean the
|
|
63
|
+
SDK has downloaded the new rules yet. `Snapshot applied` also appears for initial,
|
|
64
|
+
manual, and polling refreshes; it doesn't imply flag values actually changed.
|
|
65
|
+
|
|
66
|
+
- Initialization opens an authenticated SSE stream and starts fetching rules.
|
|
67
|
+
- The internal `@flagbearer/sdk-core` coordinator handles version tracking,
|
|
68
|
+
request coalescing, five-second request timeouts, retries, and fallback polling.
|
|
69
|
+
- Valid rules are indexed by flag key and replaced together. `isEnabled` is
|
|
70
|
+
synchronous and uses only the current in-memory rules.
|
|
71
|
+
- A newer streamed version triggers a ruleset refresh within two seconds. Older responses cannot
|
|
72
|
+
replace a newer ruleset. Reconnecting also triggers a refresh.
|
|
73
|
+
- The Node stream uses `fetch`, supports chunked SSE data and heartbeats, and
|
|
74
|
+
reconnects with jittered backoff capped at 30 seconds. Connection setup times
|
|
75
|
+
out after five seconds; 45 seconds without stream data triggers reconnection.
|
|
76
|
+
- While streaming is disconnected, rules are polled about every 30 seconds. HTTP 408,
|
|
77
|
+
429, and server errors are retried, honoring longer `Retry-After` delays.
|
|
78
|
+
- Invalid rules or permanent ruleset errors stop automatic refreshes and close
|
|
79
|
+
the stream. Existing rules remain usable. An explicit `sync()` can retry;
|
|
80
|
+
successful recovery resumes polling, not the closed stream.
|
|
81
|
+
- A permanent stream error stops stream reconnects but leaves polling available.
|
|
82
|
+
- Explicit `sync()` failures reject; background refresh failures are logged.
|
|
83
|
+
- `close()` aborts requests and streaming and clears timers. It is idempotent;
|
|
84
|
+
later `sync()` calls reject. Existing in-memory rules can still be evaluated.
|
|
85
|
+
|
|
86
|
+
Until the first valid ruleset arrives, checks use the supplied default. During an
|
|
87
|
+
outage, the last valid ruleset stays in memory. There is **no disk cache yet**:
|
|
88
|
+
restarting the application during an outage means using defaults until a fetch
|
|
89
|
+
succeeds. This SDK requires a Node runtime with built-in `fetch` and web streams.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
From the repository root:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
bun run --filter @flagbearer/node typecheck
|
|
97
|
+
bun run --filter @flagbearer/node build
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The build produces a Node-targeted ES module and self-contained TypeScript
|
|
101
|
+
declarations in `dist/`, bundling the internal refresh core, plain SDK validators,
|
|
102
|
+
and evaluator directly from their workspace sources. It does not import the
|
|
103
|
+
application's `shared` package or admin endpoint contracts. Internal packages
|
|
104
|
+
are build dependencies, not extra packages SDK consumers must install. The SDK
|
|
105
|
+
does not include Valibot or another validation library.
|
|
106
|
+
Bun is used as the build tool; the output targets Node.js.
|
|
107
|
+
|
|
108
|
+
## Integration tests
|
|
109
|
+
|
|
110
|
+
The integration suite runs this SDK in a separate Node HTTP server alongside
|
|
111
|
+
the browser SDK and an isolated backend:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
bun run test:integration
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
See [the integration README](../../tests/integration/README.md) for setup and coverage.
|
|
118
|
+
|
|
119
|
+
## Exposure reporting
|
|
120
|
+
|
|
121
|
+
Flag checks automatically report the boolean actually returned, its evaluation
|
|
122
|
+
reason (`off`, `tester`, `rollout`, or `fallback`), the configuration version, and
|
|
123
|
+
optional user key/email. Reporting runs in every environment and never waits for
|
|
124
|
+
network activity in `isEnabled`. Pass `appVersion` to `init` to identify your
|
|
125
|
+
application deployment in the stored records.
|
|
126
|
+
|
|
127
|
+
Repeated checks are counted in fixed 30-second buckets by user, flag, result,
|
|
128
|
+
reason, and configuration version. Uploads run every 10 seconds in batches of up
|
|
129
|
+
to 100 aggregates. The memory buffer holds up to 1,000 aggregates. Overflow and
|
|
130
|
+
rejected checks are reported as environment-level reporting loss when delivery
|
|
131
|
+
recovers. Records keep their IDs and contents across retries; HTTP retries do not
|
|
132
|
+
increase stored counts. The server currently accepts up to 6,000 batches per
|
|
133
|
+
minute per environment and returns `429` beyond that limit.
|
|
134
|
+
|
|
135
|
+
For shutdown or serverless invocations, explicitly await delivery before closing:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
try {
|
|
139
|
+
await flags.flush();
|
|
140
|
+
} finally {
|
|
141
|
+
flags.close();
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`flush()` rejects on upload failure so callers can choose their shutdown policy.
|
|
146
|
+
`close()` also starts a best-effort flush and stops recurring reporting. Delivery
|
|
147
|
+
is best effort: abrupt process exits can lose in-memory checks. Old SDK versions
|
|
148
|
+
do not report exposure; no observed activity does not prove a path is unused.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type SdkOptions = {
|
|
2
|
+
key: string;
|
|
3
|
+
url?: string;
|
|
4
|
+
debug?: boolean;
|
|
5
|
+
appVersion?: string;
|
|
6
|
+
};
|
|
7
|
+
export type EvaluationUser = {
|
|
8
|
+
key?: string;
|
|
9
|
+
email?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function init({ key, url, debug, appVersion, }: SdkOptions): {
|
|
12
|
+
sync: () => Promise<void>;
|
|
13
|
+
isEnabled: (flag: string, user: EvaluationUser, defaultValue?: boolean) => boolean;
|
|
14
|
+
flush: () => Promise<void>;
|
|
15
|
+
close: () => void;
|
|
16
|
+
};
|
|
17
|
+
export type Client = ReturnType<typeof init>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
// ../evaluator/src/evaluate-flags.ts
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
function bucket(flagKey, userKey) {
|
|
4
|
+
const hash = createHash("sha256").update(`${flagKey}:${userKey}`, "utf8").digest("hex");
|
|
5
|
+
return Number(BigInt(`0x${hash}`) % 10000n);
|
|
6
|
+
}
|
|
7
|
+
function evaluateFlag(flag, user, version = null, defaultValue = false) {
|
|
8
|
+
if (!flag) {
|
|
9
|
+
return {
|
|
10
|
+
value: defaultValue,
|
|
11
|
+
reason: "fallback",
|
|
12
|
+
configurationVersion: version
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (!flag.enabled) {
|
|
16
|
+
return { value: false, reason: "off", configurationVersion: version };
|
|
17
|
+
}
|
|
18
|
+
const email = user.email?.trim().toLowerCase();
|
|
19
|
+
if (email && flag.overrideEmails.some((item) => item.trim().toLowerCase() === email)) {
|
|
20
|
+
return { value: true, reason: "tester", configurationVersion: version };
|
|
21
|
+
}
|
|
22
|
+
if (!user.key && flag.rolloutBps !== 0 && flag.rolloutBps !== 1e4) {
|
|
23
|
+
return {
|
|
24
|
+
value: defaultValue,
|
|
25
|
+
reason: "fallback",
|
|
26
|
+
configurationVersion: version
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
value: user.key ? bucket(flag.key, user.key) < flag.rolloutBps : flag.rolloutBps === 1e4,
|
|
31
|
+
reason: "rollout",
|
|
32
|
+
configurationVersion: version
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// ../sdk-core/src/clock.ts
|
|
36
|
+
var realClock = {
|
|
37
|
+
now: () => Date.now(),
|
|
38
|
+
setTimeout(callback, delayMs) {
|
|
39
|
+
const timer = setTimeout(callback, delayMs);
|
|
40
|
+
return () => clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ../sdk-core/src/debug.ts
|
|
45
|
+
function createDebugLogger(enabled = false) {
|
|
46
|
+
return (message, details) => {
|
|
47
|
+
if (!enabled)
|
|
48
|
+
return;
|
|
49
|
+
try {
|
|
50
|
+
if (details)
|
|
51
|
+
console.debug(`[Flagbearer] ${message}`, details);
|
|
52
|
+
else
|
|
53
|
+
console.debug(`[Flagbearer] ${message}`);
|
|
54
|
+
} catch {}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ../sdk-core/src/validation.ts
|
|
59
|
+
function isRecord(value) {
|
|
60
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
function isVersion(value) {
|
|
63
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
64
|
+
}
|
|
65
|
+
function isFlagRule(value) {
|
|
66
|
+
if (!isRecord(value) || typeof value.key !== "string" || typeof value.enabled !== "boolean" || typeof value.rolloutBps !== "number" || !Number.isInteger(value.rolloutBps) || value.rolloutBps < 0 || value.rolloutBps > 1e4 || !Array.isArray(value.overrideEmails))
|
|
67
|
+
return false;
|
|
68
|
+
for (const email of value.overrideEmails) {
|
|
69
|
+
if (typeof email !== "string")
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
function isRuleset(value) {
|
|
75
|
+
if (!isRecord(value) || typeof value.environmentId !== "string" || !isVersion(value.version) || !Array.isArray(value.flags))
|
|
76
|
+
return false;
|
|
77
|
+
for (const flag of value.flags) {
|
|
78
|
+
if (!isFlagRule(flag))
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
function isVersionEvent(value) {
|
|
84
|
+
return isRecord(value) && isVersion(value.version);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ../sdk-core/src/refresh.ts
|
|
88
|
+
var REQUEST_TIMEOUT_MS = 5000;
|
|
89
|
+
var POLL_INTERVAL_MS = 30000;
|
|
90
|
+
var MAX_RETRY_MS = 30000;
|
|
91
|
+
var MAX_VERSION_REFRESH_DELAY_MS = 2000;
|
|
92
|
+
|
|
93
|
+
class RefreshError extends Error {
|
|
94
|
+
retryable;
|
|
95
|
+
retryAfterMs;
|
|
96
|
+
constructor(message, retryable, retryAfterMs = 0) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.retryable = retryable;
|
|
99
|
+
this.retryAfterMs = retryAfterMs;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function retryAfter(header, now) {
|
|
103
|
+
if (header === null)
|
|
104
|
+
return 0;
|
|
105
|
+
const seconds = Number(header);
|
|
106
|
+
const delay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : Date.parse(header) - now;
|
|
107
|
+
return Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
|
108
|
+
}
|
|
109
|
+
function checkResponse(response, clock = realClock) {
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new RefreshError(`Flagbearer request failed: ${response.status}`, response.status === 408 || response.status === 429 || response.status >= 500, retryAfter(response.headers.get("Retry-After"), clock.now()));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function readJsonResponse(response, signal, clock = realClock) {
|
|
115
|
+
checkResponse(response, clock);
|
|
116
|
+
try {
|
|
117
|
+
return await response.json();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (signal.aborted)
|
|
120
|
+
throw signal.reason;
|
|
121
|
+
if (error instanceof SyntaxError)
|
|
122
|
+
throw new RefreshError("Invalid snapshot JSON", false);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function wait(ms, signal, clock) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
if (signal.aborted)
|
|
129
|
+
return reject(signal.reason);
|
|
130
|
+
const cancelTimer = clock.setTimeout(() => {
|
|
131
|
+
signal.removeEventListener("abort", abort);
|
|
132
|
+
resolve();
|
|
133
|
+
}, ms);
|
|
134
|
+
function abort() {
|
|
135
|
+
cancelTimer();
|
|
136
|
+
signal.removeEventListener("abort", abort);
|
|
137
|
+
reject(signal.reason);
|
|
138
|
+
}
|
|
139
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function createRefreshCoordinator(options) {
|
|
143
|
+
const clock = options.clock ?? realClock;
|
|
144
|
+
const debug = createDebugLogger(options.debug);
|
|
145
|
+
let stopped = false;
|
|
146
|
+
let automaticBlocked = false;
|
|
147
|
+
let streaming = false;
|
|
148
|
+
let version;
|
|
149
|
+
let wantedVersion;
|
|
150
|
+
let refreshAgain = false;
|
|
151
|
+
let inFlight;
|
|
152
|
+
let activeRequest;
|
|
153
|
+
let cancelRequestTimeout;
|
|
154
|
+
let cancelRetry;
|
|
155
|
+
let cancelPolling;
|
|
156
|
+
let cancelVersionRefresh;
|
|
157
|
+
let retryAttempt = 0;
|
|
158
|
+
let notBefore = 0;
|
|
159
|
+
function clearRetry() {
|
|
160
|
+
cancelRetry?.();
|
|
161
|
+
cancelRetry = undefined;
|
|
162
|
+
}
|
|
163
|
+
function clearPolling() {
|
|
164
|
+
if (cancelPolling)
|
|
165
|
+
debug("Polling disabled");
|
|
166
|
+
cancelPolling?.();
|
|
167
|
+
cancelPolling = undefined;
|
|
168
|
+
}
|
|
169
|
+
function ensurePolling() {
|
|
170
|
+
if (options.background && !stopped && !automaticBlocked && !streaming && cancelPolling === undefined) {
|
|
171
|
+
const intervalMs = POLL_INTERVAL_MS * (0.9 + Math.random() * 0.2);
|
|
172
|
+
debug("Polling scheduled", { intervalMs: Math.ceil(intervalMs) });
|
|
173
|
+
cancelPolling = clock.setTimeout(() => {
|
|
174
|
+
cancelPolling = undefined;
|
|
175
|
+
refreshInBackground();
|
|
176
|
+
ensurePolling();
|
|
177
|
+
}, intervalMs);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function isBehindWantedVersion() {
|
|
181
|
+
return wantedVersion !== undefined && (version === undefined || version < wantedVersion);
|
|
182
|
+
}
|
|
183
|
+
function scheduleVersionRefresh() {
|
|
184
|
+
if (cancelVersionRefresh !== undefined || inFlight !== undefined)
|
|
185
|
+
return;
|
|
186
|
+
cancelVersionRefresh = clock.setTimeout(() => {
|
|
187
|
+
cancelVersionRefresh = undefined;
|
|
188
|
+
if (isBehindWantedVersion())
|
|
189
|
+
refreshInBackground();
|
|
190
|
+
}, Math.random() * MAX_VERSION_REFRESH_DELAY_MS);
|
|
191
|
+
}
|
|
192
|
+
function scheduleRetry(error) {
|
|
193
|
+
if (!options.background || stopped)
|
|
194
|
+
return;
|
|
195
|
+
const backoff = Math.min(MAX_RETRY_MS, 1000 * 2 ** Math.min(retryAttempt++, 5));
|
|
196
|
+
const jittered = Math.min(MAX_RETRY_MS, backoff * (0.8 + Math.random() * 0.4));
|
|
197
|
+
const delay = Math.max(jittered, error instanceof RefreshError ? error.retryAfterMs : 0);
|
|
198
|
+
notBefore = clock.now() + delay;
|
|
199
|
+
clearRetry();
|
|
200
|
+
debug("Refresh retry scheduled", {
|
|
201
|
+
delayMs: Math.ceil(delay),
|
|
202
|
+
attempt: retryAttempt
|
|
203
|
+
});
|
|
204
|
+
cancelRetry = clock.setTimeout(() => {
|
|
205
|
+
cancelRetry = undefined;
|
|
206
|
+
refreshInBackground();
|
|
207
|
+
}, Math.min(delay, 2147483647));
|
|
208
|
+
}
|
|
209
|
+
async function run() {
|
|
210
|
+
const controller = new AbortController;
|
|
211
|
+
activeRequest = controller;
|
|
212
|
+
try {
|
|
213
|
+
do {
|
|
214
|
+
refreshAgain = false;
|
|
215
|
+
while (clock.now() < notBefore) {
|
|
216
|
+
await wait(Math.min(notBefore - clock.now(), 2147483647), controller.signal, clock);
|
|
217
|
+
}
|
|
218
|
+
if (stopped)
|
|
219
|
+
return;
|
|
220
|
+
debug("Refresh started");
|
|
221
|
+
cancelRequestTimeout = clock.setTimeout(() => {
|
|
222
|
+
controller.abort(new DOMException("Flagbearer refresh timed out", "TimeoutError"));
|
|
223
|
+
}, REQUEST_TIMEOUT_MS);
|
|
224
|
+
const result = await options.fetchSnapshot(controller.signal);
|
|
225
|
+
if (result !== null && !isVersion(result.version)) {
|
|
226
|
+
throw new RefreshError("Invalid snapshot version", false);
|
|
227
|
+
}
|
|
228
|
+
cancelRequestTimeout?.();
|
|
229
|
+
cancelRequestTimeout = undefined;
|
|
230
|
+
if (stopped)
|
|
231
|
+
return;
|
|
232
|
+
if (result === null) {
|
|
233
|
+
debug("Snapshot unchanged", { version });
|
|
234
|
+
if (isBehindWantedVersion()) {
|
|
235
|
+
scheduleRetry(undefined);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
retryAttempt = 0;
|
|
239
|
+
notBefore = 0;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const previousVersion = version;
|
|
243
|
+
if (version === undefined || result.version >= version) {
|
|
244
|
+
version = result.version;
|
|
245
|
+
options.applySnapshot(result);
|
|
246
|
+
debug("Snapshot applied", { version, previousVersion });
|
|
247
|
+
} else {
|
|
248
|
+
debug("Older snapshot ignored", {
|
|
249
|
+
version: result.version,
|
|
250
|
+
currentVersion: version
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (stopped)
|
|
254
|
+
return;
|
|
255
|
+
if (isBehindWantedVersion()) {
|
|
256
|
+
if (previousVersion !== undefined && result.version <= previousVersion) {
|
|
257
|
+
scheduleRetry(undefined);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
refreshAgain = true;
|
|
261
|
+
} else {
|
|
262
|
+
retryAttempt = 0;
|
|
263
|
+
notBefore = 0;
|
|
264
|
+
}
|
|
265
|
+
} while (refreshAgain);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (stopped)
|
|
268
|
+
return;
|
|
269
|
+
if (error instanceof RefreshError && !error.retryable) {
|
|
270
|
+
debug("Automatic refresh stopped after permanent failure");
|
|
271
|
+
automaticBlocked = true;
|
|
272
|
+
streaming = false;
|
|
273
|
+
clearRetry();
|
|
274
|
+
clearPolling();
|
|
275
|
+
options.onPermanentFailure();
|
|
276
|
+
} else {
|
|
277
|
+
scheduleRetry(error);
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
} finally {
|
|
281
|
+
cancelRequestTimeout?.();
|
|
282
|
+
cancelRequestTimeout = undefined;
|
|
283
|
+
activeRequest = undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function begin() {
|
|
287
|
+
clearRetry();
|
|
288
|
+
inFlight = run().finally(() => {
|
|
289
|
+
inFlight = undefined;
|
|
290
|
+
});
|
|
291
|
+
return inFlight;
|
|
292
|
+
}
|
|
293
|
+
function refreshInBackground() {
|
|
294
|
+
if (stopped || automaticBlocked || cancelRetry !== undefined || inFlight !== undefined)
|
|
295
|
+
return;
|
|
296
|
+
begin().catch((error) => console.error("Flagbearer sync failed", error));
|
|
297
|
+
}
|
|
298
|
+
function sync() {
|
|
299
|
+
if (stopped)
|
|
300
|
+
return Promise.reject(new Error("Flagbearer client is closed"));
|
|
301
|
+
if (inFlight) {
|
|
302
|
+
debug("Joining active refresh");
|
|
303
|
+
return inFlight;
|
|
304
|
+
}
|
|
305
|
+
automaticBlocked = false;
|
|
306
|
+
ensurePolling();
|
|
307
|
+
return begin();
|
|
308
|
+
}
|
|
309
|
+
function onVersion(nextVersion) {
|
|
310
|
+
if (stopped)
|
|
311
|
+
return;
|
|
312
|
+
debug("Stream version received", {
|
|
313
|
+
version: nextVersion,
|
|
314
|
+
currentVersion: version
|
|
315
|
+
});
|
|
316
|
+
wantedVersion = Math.max(wantedVersion ?? 0, nextVersion);
|
|
317
|
+
if (isBehindWantedVersion())
|
|
318
|
+
scheduleVersionRefresh();
|
|
319
|
+
}
|
|
320
|
+
function setStreaming({
|
|
321
|
+
connected,
|
|
322
|
+
refresh = true
|
|
323
|
+
}) {
|
|
324
|
+
if (stopped)
|
|
325
|
+
return;
|
|
326
|
+
if (streaming !== connected) {
|
|
327
|
+
debug(connected ? "Stream connected" : "Stream disconnected; using polling");
|
|
328
|
+
}
|
|
329
|
+
streaming = connected;
|
|
330
|
+
if (connected) {
|
|
331
|
+
clearPolling();
|
|
332
|
+
if (refresh)
|
|
333
|
+
refreshInBackground();
|
|
334
|
+
} else {
|
|
335
|
+
ensurePolling();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function close() {
|
|
339
|
+
if (stopped)
|
|
340
|
+
return;
|
|
341
|
+
stopped = true;
|
|
342
|
+
debug("Coordinator closed");
|
|
343
|
+
clearRetry();
|
|
344
|
+
clearPolling();
|
|
345
|
+
cancelVersionRefresh?.();
|
|
346
|
+
cancelVersionRefresh = undefined;
|
|
347
|
+
cancelRequestTimeout?.();
|
|
348
|
+
cancelRequestTimeout = undefined;
|
|
349
|
+
activeRequest?.abort();
|
|
350
|
+
}
|
|
351
|
+
ensurePolling();
|
|
352
|
+
return { sync, refreshInBackground, onVersion, setStreaming, close };
|
|
353
|
+
}
|
|
354
|
+
// ../sdk-core/src/exposures.ts
|
|
355
|
+
var WINDOW_MS = 30000;
|
|
356
|
+
var MAX_RECORDS = 1000;
|
|
357
|
+
var BATCH_SIZE = 100;
|
|
358
|
+
function createExposureReporter(options) {
|
|
359
|
+
const reporterId = crypto.randomUUID();
|
|
360
|
+
const active = new Map;
|
|
361
|
+
const pending = [];
|
|
362
|
+
let droppedChecks = 0;
|
|
363
|
+
let reportedDroppedChecks = 0;
|
|
364
|
+
let inFlight;
|
|
365
|
+
let closed = false;
|
|
366
|
+
let retryAt = 0;
|
|
367
|
+
let failures = 0;
|
|
368
|
+
function record(flagKey, user, details) {
|
|
369
|
+
if (closed)
|
|
370
|
+
return;
|
|
371
|
+
try {
|
|
372
|
+
if (!flagKey || flagKey.length > 256 || user.key && user.key.length > 256) {
|
|
373
|
+
droppedChecks++;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
const bucket = new Date(Math.floor(now / WINDOW_MS) * WINDOW_MS).toISOString();
|
|
378
|
+
const email = user.email && user.email.length <= 254 ? user.email.trim().toLowerCase() || undefined : undefined;
|
|
379
|
+
const userKey = user.key || undefined;
|
|
380
|
+
const key = JSON.stringify([
|
|
381
|
+
flagKey,
|
|
382
|
+
userKey,
|
|
383
|
+
email,
|
|
384
|
+
details.value,
|
|
385
|
+
details.reason,
|
|
386
|
+
details.configurationVersion,
|
|
387
|
+
bucket
|
|
388
|
+
]);
|
|
389
|
+
const existing = active.get(key);
|
|
390
|
+
if (existing && existing.checkCount < 1e9) {
|
|
391
|
+
existing.checkCount++;
|
|
392
|
+
existing.lastCheckedAt = new Date(now).toISOString();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (existing || active.size + pending.length >= MAX_RECORDS) {
|
|
396
|
+
droppedChecks++;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
active.set(key, {
|
|
400
|
+
eventId: crypto.randomUUID(),
|
|
401
|
+
flagKey,
|
|
402
|
+
userKey,
|
|
403
|
+
userEmail: email,
|
|
404
|
+
result: details.value,
|
|
405
|
+
reason: details.reason,
|
|
406
|
+
configurationVersion: details.configurationVersion,
|
|
407
|
+
bucket,
|
|
408
|
+
checkCount: 1,
|
|
409
|
+
firstCheckedAt: new Date(now).toISOString(),
|
|
410
|
+
lastCheckedAt: new Date(now).toISOString()
|
|
411
|
+
});
|
|
412
|
+
} catch {
|
|
413
|
+
droppedChecks++;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function send() {
|
|
417
|
+
for (const [key, record] of active) {
|
|
418
|
+
pending.push(record);
|
|
419
|
+
active.delete(key);
|
|
420
|
+
}
|
|
421
|
+
while (pending.length > 0 || droppedChecks > reportedDroppedChecks) {
|
|
422
|
+
const batch = pending.slice(0, BATCH_SIZE);
|
|
423
|
+
const dropped = droppedChecks;
|
|
424
|
+
const response = await fetch(options.url, {
|
|
425
|
+
method: "POST",
|
|
426
|
+
credentials: "omit",
|
|
427
|
+
redirect: "error",
|
|
428
|
+
headers: {
|
|
429
|
+
"Content-Type": "application/json",
|
|
430
|
+
Authorization: `Bearer ${options.key}`
|
|
431
|
+
},
|
|
432
|
+
body: JSON.stringify({
|
|
433
|
+
reporterId,
|
|
434
|
+
events: batch,
|
|
435
|
+
droppedChecks: dropped,
|
|
436
|
+
sdk: options.sdk,
|
|
437
|
+
sdkVersion: "0.0.0",
|
|
438
|
+
appVersion: options.appVersion?.slice(0, 100)
|
|
439
|
+
}),
|
|
440
|
+
signal: AbortSignal.timeout(1e4),
|
|
441
|
+
keepalive: batch.length < 10
|
|
442
|
+
});
|
|
443
|
+
if (!response.ok) {
|
|
444
|
+
const retryAfter = Number(response.headers.get("Retry-After"));
|
|
445
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
446
|
+
retryAt = Math.max(retryAt, Date.now() + Math.min(retryAfter, 3600) * 1000);
|
|
447
|
+
}
|
|
448
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
449
|
+
droppedChecks += batch.reduce((sum, item) => sum + item.checkCount, 0);
|
|
450
|
+
pending.splice(0, batch.length);
|
|
451
|
+
retryAt = Date.now() + 60000;
|
|
452
|
+
}
|
|
453
|
+
throw new Error(`Exposure upload failed (${response.status})`);
|
|
454
|
+
}
|
|
455
|
+
const receipt = await response.json();
|
|
456
|
+
if (typeof receipt !== "object" || receipt === null || !("accepted" in receipt) || typeof receipt.accepted !== "number" || !Number.isSafeInteger(receipt.accepted) || receipt.accepted < 0 || receipt.accepted > batch.length || !("rejectedChecks" in receipt) || typeof receipt.rejectedChecks !== "number" || !Number.isSafeInteger(receipt.rejectedChecks) || receipt.rejectedChecks < 0 || receipt.rejectedChecks > batch.reduce((sum, item) => sum + item.checkCount, 0)) {
|
|
457
|
+
throw new Error("Invalid exposure upload receipt");
|
|
458
|
+
}
|
|
459
|
+
droppedChecks += receipt.rejectedChecks;
|
|
460
|
+
pending.splice(0, batch.length);
|
|
461
|
+
reportedDroppedChecks = Math.max(reportedDroppedChecks, dropped);
|
|
462
|
+
}
|
|
463
|
+
failures = 0;
|
|
464
|
+
}
|
|
465
|
+
function flush() {
|
|
466
|
+
if (inFlight)
|
|
467
|
+
return inFlight.then(() => flush());
|
|
468
|
+
inFlight = send().catch((error) => {
|
|
469
|
+
failures++;
|
|
470
|
+
retryAt = Math.max(retryAt, Date.now() + Math.min(60000, 1000 * 2 ** Math.min(failures, 6)));
|
|
471
|
+
throw error;
|
|
472
|
+
}).finally(() => {
|
|
473
|
+
inFlight = undefined;
|
|
474
|
+
});
|
|
475
|
+
return inFlight;
|
|
476
|
+
}
|
|
477
|
+
const timer = setInterval(() => {
|
|
478
|
+
if (!closed && Date.now() >= retryAt)
|
|
479
|
+
flush().catch(() => {});
|
|
480
|
+
}, 1e4);
|
|
481
|
+
timer.unref?.();
|
|
482
|
+
function close() {
|
|
483
|
+
closed = true;
|
|
484
|
+
clearInterval(timer);
|
|
485
|
+
return flush();
|
|
486
|
+
}
|
|
487
|
+
return { record, flush, close };
|
|
488
|
+
}
|
|
489
|
+
// src/stream.ts
|
|
490
|
+
var CONNECT_TIMEOUT_MS = 5000;
|
|
491
|
+
var IDLE_TIMEOUT_MS = 45000;
|
|
492
|
+
var MAX_RETRY_MS2 = 30000;
|
|
493
|
+
var MAX_EVENT_LENGTH = 65536;
|
|
494
|
+
function createVersionStream(options) {
|
|
495
|
+
const debug = createDebugLogger(options.debug);
|
|
496
|
+
let closed = false;
|
|
497
|
+
let active;
|
|
498
|
+
let timeout;
|
|
499
|
+
let retry;
|
|
500
|
+
let attempts = 0;
|
|
501
|
+
function armTimeout(controller, delay) {
|
|
502
|
+
clearTimeout(timeout);
|
|
503
|
+
timeout = setTimeout(() => controller.abort(), delay);
|
|
504
|
+
}
|
|
505
|
+
function reconnect(error) {
|
|
506
|
+
const backoff = Math.min(MAX_RETRY_MS2, 1000 * 2 ** Math.min(attempts++, 5));
|
|
507
|
+
const jittered = Math.min(MAX_RETRY_MS2, backoff * (0.8 + Math.random() * 0.4));
|
|
508
|
+
const delay = Math.max(jittered, error instanceof RefreshError ? error.retryAfterMs : 0);
|
|
509
|
+
const notBefore = Date.now() + delay;
|
|
510
|
+
debug("Stream reconnect scheduled", { delayMs: Math.ceil(delay) });
|
|
511
|
+
function schedule() {
|
|
512
|
+
if (closed)
|
|
513
|
+
return;
|
|
514
|
+
const remaining = notBefore - Date.now();
|
|
515
|
+
if (remaining <= 0) {
|
|
516
|
+
retry = undefined;
|
|
517
|
+
connect();
|
|
518
|
+
} else {
|
|
519
|
+
retry = setTimeout(schedule, Math.min(remaining, 2147483647));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
schedule();
|
|
523
|
+
}
|
|
524
|
+
async function connect() {
|
|
525
|
+
if (closed)
|
|
526
|
+
return;
|
|
527
|
+
debug("Stream connecting");
|
|
528
|
+
const controller = new AbortController;
|
|
529
|
+
active = controller;
|
|
530
|
+
let failure;
|
|
531
|
+
try {
|
|
532
|
+
let line = function(value) {
|
|
533
|
+
if (value === "") {
|
|
534
|
+
if (event === "version" && data !== "") {
|
|
535
|
+
let parsed;
|
|
536
|
+
try {
|
|
537
|
+
parsed = JSON.parse(data);
|
|
538
|
+
} catch {}
|
|
539
|
+
if (isVersionEvent(parsed))
|
|
540
|
+
options.onVersion(parsed.version);
|
|
541
|
+
}
|
|
542
|
+
event = "message";
|
|
543
|
+
data = "";
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (value.startsWith(":"))
|
|
547
|
+
return;
|
|
548
|
+
const separator = value.indexOf(":");
|
|
549
|
+
const field = separator === -1 ? value : value.slice(0, separator);
|
|
550
|
+
let content = separator === -1 ? "" : value.slice(separator + 1);
|
|
551
|
+
if (content.startsWith(" "))
|
|
552
|
+
content = content.slice(1);
|
|
553
|
+
if (field === "event")
|
|
554
|
+
event = content;
|
|
555
|
+
if (field === "data")
|
|
556
|
+
data += `${content}
|
|
557
|
+
`;
|
|
558
|
+
if (data.length > MAX_EVENT_LENGTH)
|
|
559
|
+
throw new Error("Flagbearer stream event too large");
|
|
560
|
+
};
|
|
561
|
+
armTimeout(controller, CONNECT_TIMEOUT_MS);
|
|
562
|
+
const response = await fetch(options.url, {
|
|
563
|
+
headers: { ...options.headers, Accept: "text/event-stream" },
|
|
564
|
+
signal: controller.signal,
|
|
565
|
+
credentials: "omit",
|
|
566
|
+
cache: "no-store",
|
|
567
|
+
redirect: "error"
|
|
568
|
+
});
|
|
569
|
+
checkResponse(response);
|
|
570
|
+
const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
|
|
571
|
+
if (contentType !== "text/event-stream" || !response.body) {
|
|
572
|
+
throw new RefreshError("Invalid Flagbearer stream response", false);
|
|
573
|
+
}
|
|
574
|
+
if (closed)
|
|
575
|
+
return;
|
|
576
|
+
armTimeout(controller, IDLE_TIMEOUT_MS);
|
|
577
|
+
options.onOpen();
|
|
578
|
+
const decoder = new TextDecoder;
|
|
579
|
+
const reader = response.body.getReader();
|
|
580
|
+
let buffer = "";
|
|
581
|
+
let event = "message";
|
|
582
|
+
let data = "";
|
|
583
|
+
try {
|
|
584
|
+
while (!closed) {
|
|
585
|
+
const chunk = await reader.read();
|
|
586
|
+
if (chunk.done)
|
|
587
|
+
break;
|
|
588
|
+
armTimeout(controller, IDLE_TIMEOUT_MS);
|
|
589
|
+
attempts = 0;
|
|
590
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
591
|
+
let start = 0;
|
|
592
|
+
for (let index = 0;index < buffer.length; index++) {
|
|
593
|
+
const char = buffer[index];
|
|
594
|
+
if (char !== "\r" && char !== `
|
|
595
|
+
`)
|
|
596
|
+
continue;
|
|
597
|
+
if (char === "\r" && index === buffer.length - 1)
|
|
598
|
+
break;
|
|
599
|
+
line(buffer.slice(start, index));
|
|
600
|
+
if (char === "\r" && buffer[index + 1] === `
|
|
601
|
+
`)
|
|
602
|
+
index++;
|
|
603
|
+
start = index + 1;
|
|
604
|
+
}
|
|
605
|
+
buffer = buffer.slice(start);
|
|
606
|
+
if (buffer.length > MAX_EVENT_LENGTH)
|
|
607
|
+
throw new Error("Flagbearer stream line too large");
|
|
608
|
+
}
|
|
609
|
+
} finally {
|
|
610
|
+
reader.releaseLock();
|
|
611
|
+
}
|
|
612
|
+
} catch (error) {
|
|
613
|
+
failure = error;
|
|
614
|
+
} finally {
|
|
615
|
+
clearTimeout(timeout);
|
|
616
|
+
controller.abort();
|
|
617
|
+
active = undefined;
|
|
618
|
+
}
|
|
619
|
+
if (closed)
|
|
620
|
+
return;
|
|
621
|
+
options.onDisconnect();
|
|
622
|
+
if (failure instanceof RefreshError && !failure.retryable) {
|
|
623
|
+
console.error("Flagbearer stream stopped; using polling", failure);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
reconnect(failure);
|
|
627
|
+
}
|
|
628
|
+
function close() {
|
|
629
|
+
if (closed)
|
|
630
|
+
return;
|
|
631
|
+
closed = true;
|
|
632
|
+
debug("Stream closed");
|
|
633
|
+
clearTimeout(timeout);
|
|
634
|
+
clearTimeout(retry);
|
|
635
|
+
active?.abort();
|
|
636
|
+
}
|
|
637
|
+
connect();
|
|
638
|
+
return { close };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/index.ts
|
|
642
|
+
var DEFAULT_URL = "https://sdk.flagbearer.dev";
|
|
643
|
+
function init({
|
|
644
|
+
key,
|
|
645
|
+
url = DEFAULT_URL,
|
|
646
|
+
debug = false,
|
|
647
|
+
appVersion
|
|
648
|
+
}) {
|
|
649
|
+
if (!key || /\s/.test(key))
|
|
650
|
+
throw new Error("A nonempty server SDK key is required");
|
|
651
|
+
const baseUrl = new URL(url);
|
|
652
|
+
if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
|
|
653
|
+
throw new Error("Flagbearer url must use HTTP or HTTPS");
|
|
654
|
+
}
|
|
655
|
+
const rulesetUrl = new URL("/v1/ruleset", baseUrl);
|
|
656
|
+
const streamUrl = new URL("/v1/stream", baseUrl);
|
|
657
|
+
const headers = { Authorization: `Bearer ${key}` };
|
|
658
|
+
const reporter = createExposureReporter({
|
|
659
|
+
url: new URL("/v1/exposures", baseUrl),
|
|
660
|
+
key,
|
|
661
|
+
sdk: "node",
|
|
662
|
+
appVersion
|
|
663
|
+
});
|
|
664
|
+
let configurationVersion = null;
|
|
665
|
+
let rules = new Map;
|
|
666
|
+
let environmentId;
|
|
667
|
+
let stream;
|
|
668
|
+
let hasConnected = false;
|
|
669
|
+
let closed = false;
|
|
670
|
+
const coordinator = createRefreshCoordinator({
|
|
671
|
+
debug,
|
|
672
|
+
async fetchSnapshot(signal) {
|
|
673
|
+
const response = await fetch(rulesetUrl, {
|
|
674
|
+
method: "GET",
|
|
675
|
+
headers: configurationVersion === null ? headers : { ...headers, "If-None-Match": `"${configurationVersion}"` },
|
|
676
|
+
signal,
|
|
677
|
+
credentials: "omit",
|
|
678
|
+
cache: "no-store",
|
|
679
|
+
redirect: "error"
|
|
680
|
+
});
|
|
681
|
+
if (response.status === 304)
|
|
682
|
+
return null;
|
|
683
|
+
const result = await readJsonResponse(response, signal);
|
|
684
|
+
if (!isRuleset(result))
|
|
685
|
+
throw new RefreshError("Invalid ruleset response", false);
|
|
686
|
+
if (environmentId !== undefined && result.environmentId !== environmentId) {
|
|
687
|
+
throw new RefreshError("Ruleset environment changed unexpectedly", false);
|
|
688
|
+
}
|
|
689
|
+
return result;
|
|
690
|
+
},
|
|
691
|
+
applySnapshot(snapshot) {
|
|
692
|
+
const next = new Map(snapshot.flags.map((rule) => [rule.key, rule]));
|
|
693
|
+
environmentId = snapshot.environmentId;
|
|
694
|
+
configurationVersion = snapshot.version;
|
|
695
|
+
rules = next;
|
|
696
|
+
},
|
|
697
|
+
background: true,
|
|
698
|
+
onPermanentFailure: () => stream?.close()
|
|
699
|
+
});
|
|
700
|
+
function isEnabled(flag, user, defaultValue = false) {
|
|
701
|
+
let details;
|
|
702
|
+
try {
|
|
703
|
+
details = evaluateFlag(rules.get(flag), user, configurationVersion, defaultValue);
|
|
704
|
+
} catch {
|
|
705
|
+
details = {
|
|
706
|
+
value: defaultValue,
|
|
707
|
+
reason: "fallback",
|
|
708
|
+
configurationVersion
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
reporter.record(flag, user, details);
|
|
712
|
+
return details.value;
|
|
713
|
+
}
|
|
714
|
+
function close() {
|
|
715
|
+
if (closed)
|
|
716
|
+
return;
|
|
717
|
+
closed = true;
|
|
718
|
+
coordinator.close();
|
|
719
|
+
stream?.close();
|
|
720
|
+
reporter.close().catch(() => {});
|
|
721
|
+
}
|
|
722
|
+
stream = createVersionStream({
|
|
723
|
+
debug,
|
|
724
|
+
url: streamUrl,
|
|
725
|
+
headers,
|
|
726
|
+
onVersion: coordinator.onVersion,
|
|
727
|
+
onOpen() {
|
|
728
|
+
coordinator.setStreaming({ connected: true, refresh: hasConnected });
|
|
729
|
+
hasConnected = true;
|
|
730
|
+
},
|
|
731
|
+
onDisconnect: () => coordinator.setStreaming({ connected: false })
|
|
732
|
+
});
|
|
733
|
+
coordinator.refreshInBackground();
|
|
734
|
+
return { sync: coordinator.sync, isEnabled, flush: reporter.flush, close };
|
|
735
|
+
}
|
|
736
|
+
export {
|
|
737
|
+
init
|
|
738
|
+
};
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type StreamOptions = {
|
|
2
|
+
url: URL;
|
|
3
|
+
headers: Record<string, string>;
|
|
4
|
+
debug?: boolean;
|
|
5
|
+
onVersion(version: number): void;
|
|
6
|
+
onOpen(): void;
|
|
7
|
+
onDisconnect(): void;
|
|
8
|
+
};
|
|
9
|
+
export declare function createVersionStream(options: StreamOptions): {
|
|
10
|
+
close: () => void;
|
|
11
|
+
};
|
|
12
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flagbearer/node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Flagbearer Node.js SDK",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://flagbearer.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ajsmth/flagbearer.git",
|
|
10
|
+
"directory": "packages/sdk-node"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"feature-flags",
|
|
14
|
+
"flagbearer"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "bun build ./src/index.ts --target node --format esm --outdir ./dist && tsc -p tsconfig.json --emitDeclarationOnly",
|
|
38
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
39
|
+
"prepack": "bun run build"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@flagbearer/evaluator": "workspace:*",
|
|
43
|
+
"@flagbearer/sdk-core": "workspace:*",
|
|
44
|
+
"@types/node": "^26.6.3"
|
|
45
|
+
}
|
|
46
|
+
}
|