@ezghcloud/flags-openfeature 0.0.0 → 1.0.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/dist/server.js ADDED
@@ -0,0 +1,398 @@
1
+ // EzghProvider: the OpenFeature server provider (Node, Bun, Deno). It holds the environment's
2
+ // current snapshot and evaluates every flag locally through flagd's core; nothing is sent per
3
+ // evaluation. It loads the snapshot (GET /v1/sync/snapshot), then follows POST /v1/sync/stream
4
+ // for new versions. If the stream keeps failing it polls the snapshot with If-None-Match while it
5
+ // retries the stream with backoff. The only state it keeps is the current snapshot.
6
+ import { ErrorCode, GeneralError, OpenFeatureEventEmitter, ProviderEvents, ProviderFatalError, } from "@openfeature/server-sdk";
7
+ import { changedKeys, Evaluator } from "./shared/evaluator.js";
8
+ import { backoffMs, childController, isFatalStatus, retryAfterMs, sleep, trimEndpoint } from "./shared/http.js";
9
+ import { endReason, isEventStream, watchedSSE } from "./shared/sse.js";
10
+ export { DEFAULT_ENDPOINT } from "./shared/http.js";
11
+ const STREAM_FAILURES_BEFORE_POLLING = 3;
12
+ const defaultLogger = {
13
+ error: (...a) => console.error("[ezgh-flags]", ...a),
14
+ warn: (...a) => console.warn("[ezgh-flags]", ...a),
15
+ info() { },
16
+ debug() { },
17
+ };
18
+ export class EzghProvider {
19
+ metadata = { name: "ezgh-flags" };
20
+ runsOn = "server";
21
+ events = new OpenFeatureEventEmitter();
22
+ #key;
23
+ #endpoint;
24
+ #bootstrapPath;
25
+ #offline;
26
+ #fetch;
27
+ #log;
28
+ #timeoutMs;
29
+ #pollMs;
30
+ #staleMs;
31
+ #minBackoff;
32
+ #maxBackoff;
33
+ #idleMs;
34
+ /** Made by each initialize, so the provider can be initialized again after onClose. */
35
+ #abort = new AbortController();
36
+ #ev = null;
37
+ /** The snapshot came from the data plane (not the bootstrap file), so its version can be named to it. */
38
+ #fromNetwork = false;
39
+ #init = "pending";
40
+ #fatal = false;
41
+ #connected = false;
42
+ #stale = false;
43
+ #lastContact = Date.now();
44
+ #staleTimer;
45
+ #polling = null;
46
+ #running = false;
47
+ constructor(options) {
48
+ this.#offline = options.offline ?? false;
49
+ this.#bootstrapPath = options.bootstrapPath;
50
+ if (this.#offline && !this.#bootstrapPath)
51
+ throw new TypeError("EzghProvider: offline needs a bootstrapPath");
52
+ const key = options.sdkKey ?? "";
53
+ if (!this.#offline && !key.startsWith("ezgh_fs_")) {
54
+ throw new TypeError("EzghProvider needs a server key (ezgh_fs_…); client keys (ezgh_fc_…) are for EzghWebProvider");
55
+ }
56
+ this.#key = key;
57
+ this.#endpoint = trimEndpoint(options.endpoint);
58
+ this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
59
+ this.#log = options.logger ?? defaultLogger;
60
+ this.#timeoutMs = options.timeoutMs ?? 10_000;
61
+ this.#pollMs = options.pollIntervalMs ?? 30_000;
62
+ this.#staleMs = options.staleAfterMs ?? 30_000;
63
+ this.#minBackoff = options.minBackoffMs ?? 1_000;
64
+ this.#maxBackoff = options.maxBackoffMs ?? 60_000;
65
+ this.#idleMs = options.idleTimeoutMs ?? 45_000;
66
+ }
67
+ async initialize() {
68
+ this.#abort.abort();
69
+ this.#abort = new AbortController();
70
+ clearTimeout(this.#staleTimer);
71
+ this.#ev = null;
72
+ this.#fromNetwork = false;
73
+ this.#init = "pending";
74
+ this.#fatal = false;
75
+ this.#connected = false;
76
+ this.#stale = false;
77
+ this.#lastContact = Date.now();
78
+ this.#polling = null;
79
+ this.#running = false;
80
+ if (this.#bootstrapPath) {
81
+ const { readFile } = await import("node:fs/promises");
82
+ this.#apply(await readFile(this.#bootstrapPath, "utf8"), { throwOnInvalid: true, bootstrap: true });
83
+ }
84
+ if (this.#offline) {
85
+ this.#init = "done";
86
+ return;
87
+ }
88
+ if (!this.#ev) {
89
+ try {
90
+ await this.#initialFetch();
91
+ }
92
+ catch (e) {
93
+ this.#init = "failed";
94
+ if (!this.#fatal)
95
+ this.#startSync();
96
+ throw e;
97
+ }
98
+ }
99
+ else {
100
+ // Ready from the bootstrap file without reaching the data plane: STALE if it stays unreachable.
101
+ this.#scheduleStale();
102
+ }
103
+ this.#init = "done";
104
+ this.#startSync();
105
+ }
106
+ async onClose() {
107
+ this.#abort.abort();
108
+ clearTimeout(this.#staleTimer);
109
+ }
110
+ async resolveBooleanEvaluation(flagKey, defaultValue, context) {
111
+ return this.#resolve("boolean", flagKey, defaultValue, context);
112
+ }
113
+ async resolveStringEvaluation(flagKey, defaultValue, context) {
114
+ return this.#resolve("string", flagKey, defaultValue, context);
115
+ }
116
+ async resolveNumberEvaluation(flagKey, defaultValue, context) {
117
+ return this.#resolve("number", flagKey, defaultValue, context);
118
+ }
119
+ async resolveObjectEvaluation(flagKey, defaultValue, context) {
120
+ return this.#resolve("object", flagKey, defaultValue, context);
121
+ }
122
+ #resolve(type, key, defaultValue, context) {
123
+ const ev = this.#ev;
124
+ if (!ev)
125
+ return { value: defaultValue, reason: "ERROR", errorCode: ErrorCode.PROVIDER_NOT_READY };
126
+ return ev.resolve(type, key, defaultValue, context);
127
+ }
128
+ // ---- snapshots
129
+ /**
130
+ * Swaps in a newer snapshot and announces it: PROVIDER_CONFIGURATION_CHANGED on every new
131
+ * version, with the flags whose answers may have changed (possibly none) and the snapshot
132
+ * version. A snapshot from the data plane always replaces a bootstrap file's, whatever their
133
+ * versions; between data-plane snapshots only a newer version counts.
134
+ */
135
+ #apply(json, opts = {}) {
136
+ let next;
137
+ try {
138
+ next = new Evaluator(json);
139
+ }
140
+ catch (e) {
141
+ if (opts.throwOnInvalid)
142
+ throw new GeneralError(`EzghProvider: ${e.message}`);
143
+ this.#log.warn(`ignored an invalid snapshot: ${e.message}`);
144
+ return;
145
+ }
146
+ const prev = this.#ev;
147
+ if (prev && this.#fromNetwork && next.version <= prev.version)
148
+ return;
149
+ this.#ev = next;
150
+ this.#fromNetwork = !opts.bootstrap;
151
+ if (!prev) {
152
+ if (this.#init === "failed")
153
+ this.events.emit(ProviderEvents.Ready);
154
+ return;
155
+ }
156
+ const flagsChanged = changedKeys(prev.fingerprints(), next.fingerprints());
157
+ this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged, metadata: { snapshotVersion: next.version } });
158
+ }
159
+ #headers(extra = {}) {
160
+ return { Authorization: `Bearer ${this.#key}`, ...extra };
161
+ }
162
+ /** GET /v1/sync/snapshot until it answers, the key is refused (fatal) or the timeout passes. */
163
+ async #initialFetch() {
164
+ const deadline = Date.now() + this.#timeoutMs;
165
+ const ctl = childController(this.#abort.signal);
166
+ const timer = setTimeout(() => ctl.abort(), this.#timeoutMs);
167
+ try {
168
+ for (let attempt = 1;; attempt++) {
169
+ let wait;
170
+ try {
171
+ const res = await this.#fetch(`${this.#endpoint}/v1/sync/snapshot`, { headers: this.#headers(), signal: ctl.signal });
172
+ if (res.ok) {
173
+ this.#apply(await res.text(), { throwOnInvalid: true });
174
+ this.#contact();
175
+ return;
176
+ }
177
+ await res.body?.cancel();
178
+ if (isFatalStatus(res.status))
179
+ throw this.#fatalize(`the data plane refused the SDK key (${res.status})`);
180
+ wait = retryAfterMs(res);
181
+ }
182
+ catch (e) {
183
+ if (e instanceof ProviderFatalError || e instanceof GeneralError)
184
+ throw e;
185
+ if (ctl.signal.aborted)
186
+ break;
187
+ }
188
+ await sleep(Math.min(backoffMs(attempt, this.#minBackoff, this.#maxBackoff, wait), Math.max(0, deadline - Date.now())), ctl.signal);
189
+ if (ctl.signal.aborted || Date.now() >= deadline)
190
+ break;
191
+ }
192
+ }
193
+ finally {
194
+ clearTimeout(timer);
195
+ ctl.abort();
196
+ }
197
+ throw new GeneralError(`EzghProvider: no snapshot from ${this.#endpoint} within ${this.#timeoutMs} ms`);
198
+ }
199
+ // ---- the stream, polling and staleness
200
+ #startSync() {
201
+ if (this.#running || this.#abort.signal.aborted)
202
+ return;
203
+ this.#running = true;
204
+ void this.#syncLoop(this.#abort.signal);
205
+ }
206
+ /** Follows the stream until `signal` (this run's) aborts or the key is refused. */
207
+ async #syncLoop(signal) {
208
+ let failures = 0;
209
+ while (!signal.aborted && !this.#fatal) {
210
+ let out;
211
+ try {
212
+ out = await this.#streamOnce(signal);
213
+ }
214
+ catch (e) {
215
+ // Anything unexpected is a transient failure, never a silent stop.
216
+ this.#log.warn(`sync stream: ${e.message}`);
217
+ out = { kind: "failed", established: false };
218
+ }
219
+ if (out.kind === "fatal" || signal.aborted)
220
+ return;
221
+ if (out.kind === "end") {
222
+ if (out.reason === "revoked" || out.reason === "environmentDeleted") {
223
+ this.#fatalize(out.reason === "revoked" ? "the SDK key was revoked" : "the SDK key's environment was deleted");
224
+ return;
225
+ }
226
+ if (out.reason === "rotate") {
227
+ failures = 0;
228
+ continue; // the only reason to reconnect at once
229
+ }
230
+ if (out.reason === "shutdown") {
231
+ // The pod is draining: reconnect (to another one) after a little jitter.
232
+ failures = 0;
233
+ await sleep(Math.random() * this.#minBackoff, signal);
234
+ continue;
235
+ }
236
+ // An unknown reason is a failed attempt.
237
+ }
238
+ else if (out.kind === "failed" && out.established) {
239
+ // A stream that was up and dropped: reconnect soon; it isn't a failed attempt.
240
+ failures = 0;
241
+ await sleep(backoffMs(1, this.#minBackoff, this.#maxBackoff, out.retryAfter), signal);
242
+ continue;
243
+ }
244
+ failures++;
245
+ if (failures >= STREAM_FAILURES_BEFORE_POLLING)
246
+ this.#startPolling();
247
+ await sleep(backoffMs(failures, this.#minBackoff, this.#maxBackoff, out.kind === "failed" ? out.retryAfter : undefined), signal);
248
+ }
249
+ }
250
+ /** One stream connection, from the request until it ends. */
251
+ async #streamOnce(signal) {
252
+ const ctl = childController(signal);
253
+ try {
254
+ return await this.#streamWith(ctl);
255
+ }
256
+ finally {
257
+ ctl.abort();
258
+ }
259
+ }
260
+ async #streamWith(ctl) {
261
+ let res;
262
+ try {
263
+ const body = this.#ev && this.#fromNetwork ? JSON.stringify({ version: this.#ev.version }) : "{}";
264
+ res = await this.#fetch(`${this.#endpoint}/v1/sync/stream`, {
265
+ method: "POST",
266
+ headers: this.#headers({ "Content-Type": "application/json", Accept: "text/event-stream" }),
267
+ body,
268
+ signal: ctl.signal,
269
+ });
270
+ }
271
+ catch {
272
+ return { kind: "failed", established: false };
273
+ }
274
+ if (!res.ok || !res.body || !isEventStream(res)) {
275
+ await res.body?.cancel();
276
+ if (isFatalStatus(res.status)) {
277
+ this.#fatalize(`the data plane refused the SDK key (${res.status})`);
278
+ return { kind: "fatal" };
279
+ }
280
+ const retryAfter = retryAfterMs(res);
281
+ return retryAfter === undefined ? { kind: "failed", established: false } : { kind: "failed", established: false, retryAfter };
282
+ }
283
+ // Connected only once the stream sends its first byte (the opening comment).
284
+ let established = false;
285
+ const onActivity = () => {
286
+ if (established) {
287
+ this.#lastContact = Date.now();
288
+ return;
289
+ }
290
+ established = true;
291
+ this.#connected = true;
292
+ this.#stopPolling();
293
+ this.#contact();
294
+ };
295
+ try {
296
+ for await (const ev of watchedSSE(res.body, ctl, this.#idleMs, onActivity)) {
297
+ if (ev.event === "snapshot")
298
+ this.#apply(ev.data);
299
+ else if (ev.event === "end")
300
+ return { kind: "end", reason: endReason(ev.data) };
301
+ }
302
+ return { kind: "failed", established };
303
+ }
304
+ catch {
305
+ return { kind: "failed", established };
306
+ }
307
+ finally {
308
+ if (established) {
309
+ this.#connected = false;
310
+ if (!this.#fatal)
311
+ this.#scheduleStale();
312
+ }
313
+ }
314
+ }
315
+ #startPolling() {
316
+ if (this.#polling || this.#abort.signal.aborted)
317
+ return;
318
+ const ctl = childController(this.#abort.signal);
319
+ this.#polling = ctl;
320
+ void (async () => {
321
+ while (!ctl.signal.aborted && !this.#fatal) {
322
+ const wait = await this.#pollOnce(ctl.signal).catch(() => undefined);
323
+ await sleep(Math.max(this.#pollMs, Math.min(wait ?? 0, this.#maxBackoff)), ctl.signal);
324
+ }
325
+ })();
326
+ }
327
+ #stopPolling() {
328
+ this.#polling?.abort();
329
+ this.#polling = null;
330
+ }
331
+ /** One GET /v1/sync/snapshot with If-None-Match; answers a Retry-After to honour, if any. */
332
+ async #pollOnce(signal) {
333
+ try {
334
+ const headers = this.#ev && this.#fromNetwork ? this.#headers({ "If-None-Match": `"${this.#ev.version}"` }) : this.#headers();
335
+ const res = await this.#fetch(`${this.#endpoint}/v1/sync/snapshot`, { headers, signal });
336
+ if (res.status === 200) {
337
+ this.#apply(await res.text());
338
+ this.#contact();
339
+ return undefined;
340
+ }
341
+ await res.body?.cancel();
342
+ if (res.status === 304) {
343
+ this.#contact();
344
+ return undefined;
345
+ }
346
+ if (isFatalStatus(res.status))
347
+ this.#fatalize(`the data plane refused the SDK key (${res.status})`);
348
+ return retryAfterMs(res);
349
+ }
350
+ catch {
351
+ return undefined;
352
+ }
353
+ }
354
+ /** The data plane answered: not stale (any more). */
355
+ #contact() {
356
+ this.#lastContact = Date.now();
357
+ if (this.#stale) {
358
+ this.#stale = false;
359
+ if (!this.#fatal)
360
+ this.events.emit(ProviderEvents.Ready);
361
+ }
362
+ if (this.#connected)
363
+ clearTimeout(this.#staleTimer);
364
+ else
365
+ this.#scheduleStale();
366
+ }
367
+ /** While disconnected: stale once the last contact is older than the threshold. */
368
+ #scheduleStale() {
369
+ clearTimeout(this.#staleTimer);
370
+ if (this.#stale || this.#connected || this.#abort.signal.aborted)
371
+ return;
372
+ // While polling, a poll is due every pollIntervalMs; allow two before calling the data stale.
373
+ const threshold = this.#polling ? Math.max(this.#staleMs, 2 * this.#pollMs) : this.#staleMs;
374
+ const due = this.#lastContact + threshold - Date.now();
375
+ this.#staleTimer = setTimeout(() => {
376
+ if (this.#connected || this.#fatal || this.#abort.signal.aborted)
377
+ return;
378
+ if (Date.now() - this.#lastContact < threshold)
379
+ return this.#scheduleStale();
380
+ this.#stale = true;
381
+ this.events.emit(ProviderEvents.Stale, { message: `no contact with ${this.#endpoint} for ${threshold} ms` });
382
+ }, Math.max(0, due));
383
+ }
384
+ /** The key was refused: stop everything. Answers the error for initialize to throw. */
385
+ #fatalize(message) {
386
+ const err = new ProviderFatalError(`EzghProvider: ${message}`);
387
+ if (this.#fatal)
388
+ return err;
389
+ this.#fatal = true;
390
+ clearTimeout(this.#staleTimer);
391
+ this.#stopPolling();
392
+ this.#abort.abort();
393
+ this.#log.error(err.message);
394
+ if (this.#init !== "pending")
395
+ this.events.emit(ProviderEvents.Error, { errorCode: ErrorCode.PROVIDER_FATAL, message: err.message });
396
+ return err;
397
+ }
398
+ }
@@ -0,0 +1,33 @@
1
+ import type { EvaluationContext, JsonValue, Logger, ResolutionDetails } from "@openfeature/core";
2
+ export type FlagType = "boolean" | "string" | "number" | "object";
3
+ export declare class Evaluator {
4
+ #private;
5
+ /** The snapshot's `metadata.snapshotVersion`. */
6
+ readonly version: number;
7
+ /**
8
+ * Loads a snapshot (flagd JSON as flags-data serves it). Throws on an invalid snapshot. The
9
+ * core runs without dynamic code generation (it works in every runtime, edge ones included);
10
+ * `dynamicCodeGeneration: true` uses the core's compiled mode, which the conformance suite also
11
+ * runs.
12
+ */
13
+ constructor(snapshotJson: string, logger?: Logger, options?: {
14
+ dynamicCodeGeneration?: boolean;
15
+ });
16
+ /**
17
+ * A fingerprint of each flag's definition (variants, default, targeting with the segments it
18
+ * references resolved, version, enabled), by key. Two snapshots' fingerprints differ for a flag
19
+ * exactly when its answers may differ, so a segment edit, which changes no flagVersion, still
20
+ * shows up in `changedKeys`.
21
+ */
22
+ fingerprints(): Map<string, string>;
23
+ /** Each flag's `metadata.flagVersion` (its config version), by key. */
24
+ flagVersions(): Map<string, number>;
25
+ /** The snapshot's flag keys, sorted. */
26
+ keys(): string[];
27
+ resolve<T extends JsonValue>(type: FlagType, key: string, defaultValue: T, ctx: EvaluationContext): ResolutionDetails<T>;
28
+ }
29
+ /**
30
+ * The keys whose value changed, appeared or disappeared between two maps (`flagVersions()` or
31
+ * `fingerprints()`), sorted.
32
+ */
33
+ export declare function changedKeys<V>(before: Map<string, V>, after: Map<string, V>): string[];
@@ -0,0 +1,111 @@
1
+ // The shared evaluator: flagd's TypeScript core (@openfeature/flagd-core, pinned) over one EZGH
2
+ // snapshot, with EZGH's one reason rule (plan PD7): a flag whose metadata says `enabled: false`
3
+ // is an off config, which the core answers with the off value and reason STATIC; EZGH reports
4
+ // DISABLED. Nothing else rewrites what the core answers.
5
+ import { FlagdCore } from "@openfeature/flagd-core";
6
+ /** A no-op logger: the core's default logs targeting errors to the console. */
7
+ const quiet = { error() { }, warn() { }, info() { }, debug() { } };
8
+ export class Evaluator {
9
+ /** The snapshot's `metadata.snapshotVersion`. */
10
+ version;
11
+ #core;
12
+ #fingerprints;
13
+ /**
14
+ * Loads a snapshot (flagd JSON as flags-data serves it). Throws on an invalid snapshot. The
15
+ * core runs without dynamic code generation (it works in every runtime, edge ones included);
16
+ * `dynamicCodeGeneration: true` uses the core's compiled mode, which the conformance suite also
17
+ * runs.
18
+ */
19
+ constructor(snapshotJson, logger = quiet, options = {}) {
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(snapshotJson);
23
+ }
24
+ catch (e) {
25
+ throw new Error(`invalid snapshot: ${e.message}`);
26
+ }
27
+ if (!isObject(parsed) || !isObject(parsed.flags))
28
+ throw new Error("invalid snapshot: no flags object");
29
+ const meta = isObject(parsed.metadata) ? parsed.metadata : {};
30
+ const version = meta.snapshotVersion;
31
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 0) {
32
+ throw new Error("invalid snapshot: metadata.snapshotVersion isn't a non-negative integer");
33
+ }
34
+ this.version = version;
35
+ this.#core = new FlagdCore(undefined, logger, { disableDynamicCodeGeneration: !options.dynamicCodeGeneration });
36
+ this.#core.setConfigurations(snapshotJson);
37
+ this.#fingerprints = fingerprints(parsed.flags, isObject(parsed.$evaluators) ? parsed.$evaluators : {});
38
+ }
39
+ /**
40
+ * A fingerprint of each flag's definition (variants, default, targeting with the segments it
41
+ * references resolved, version, enabled), by key. Two snapshots' fingerprints differ for a flag
42
+ * exactly when its answers may differ, so a segment edit, which changes no flagVersion, still
43
+ * shows up in `changedKeys`.
44
+ */
45
+ fingerprints() {
46
+ return this.#fingerprints;
47
+ }
48
+ /** Each flag's `metadata.flagVersion` (its config version), by key. */
49
+ flagVersions() {
50
+ const out = new Map();
51
+ for (const [key, flag] of this.#core.getFlags()) {
52
+ const v = flag.metadata.flagVersion;
53
+ out.set(key, typeof v === "number" ? v : 0);
54
+ }
55
+ return out;
56
+ }
57
+ /** The snapshot's flag keys, sorted. */
58
+ keys() {
59
+ return [...this.#core.getFlags().keys()].sort();
60
+ }
61
+ resolve(type, key, defaultValue, ctx) {
62
+ const r = this.#core.resolve(type, key, defaultValue, ctx);
63
+ if (r.errorCode !== undefined) {
64
+ const out = { value: defaultValue, reason: r.reason ?? "ERROR", errorCode: r.errorCode };
65
+ if (r.errorMessage !== undefined)
66
+ out.errorMessage = r.errorMessage;
67
+ return out;
68
+ }
69
+ const flagMetadata = {};
70
+ if (typeof r.flagMetadata.flagVersion === "number")
71
+ flagMetadata.flagVersion = r.flagMetadata.flagVersion;
72
+ const out = { value: r.value, reason: r.reason, flagMetadata };
73
+ if (r.variant !== undefined)
74
+ out.variant = r.variant;
75
+ if (r.flagMetadata.enabled === false)
76
+ out.reason = "DISABLED";
77
+ return out;
78
+ }
79
+ }
80
+ /**
81
+ * The keys whose value changed, appeared or disappeared between two maps (`flagVersions()` or
82
+ * `fingerprints()`), sorted.
83
+ */
84
+ export function changedKeys(before, after) {
85
+ const out = [];
86
+ for (const [k, v] of after)
87
+ if (before.get(k) !== v)
88
+ out.push(k);
89
+ for (const k of before.keys())
90
+ if (!after.has(k))
91
+ out.push(k);
92
+ return out.sort();
93
+ }
94
+ function fingerprints(flags, evaluators) {
95
+ const refs = new Map(Object.entries(evaluators).map(([k, v]) => [JSON.stringify({ $ref: k }), JSON.stringify(v)]));
96
+ const out = new Map();
97
+ for (const [key, def] of Object.entries(flags)) {
98
+ if (!isObject(def))
99
+ continue;
100
+ const meta = isObject(def.metadata) ? def.metadata : {};
101
+ let fp = JSON.stringify([def.state, def.variants, def.defaultVariant, def.targeting ?? null, meta.flagVersion ?? null, meta.enabled ?? null]);
102
+ // flagd substitutes {"$ref": "<segment>"} textually; so does the fingerprint.
103
+ for (const [ref, body] of refs)
104
+ fp = fp.replaceAll(ref, body);
105
+ out.set(key, fp);
106
+ }
107
+ return out;
108
+ }
109
+ function isObject(v) {
110
+ return typeof v === "object" && v !== null && !Array.isArray(v);
111
+ }
@@ -0,0 +1,16 @@
1
+ export declare const DEFAULT_ENDPOINT = "https://flags.ezghcloud.com";
2
+ export declare function trimEndpoint(endpoint: string | undefined): string;
3
+ /** A Retry-After header (seconds or an HTTP date) in milliseconds, or undefined. */
4
+ export declare function retryAfterMs(res: Response, now?: number): number | undefined;
5
+ /**
6
+ * Exponential backoff with jitter: attempt 1 waits between min/2 and min, doubling up to max, and
7
+ * never less than the server's Retry-After, itself capped at max (a broken or hostile Retry-After
8
+ * can't park a provider for hours).
9
+ */
10
+ export declare function backoffMs(attempt: number, minMs: number, maxMs: number, retryAfter?: number): number;
11
+ /** Resolves after ms, or at once when the signal aborts. */
12
+ export declare function sleep(ms: number, signal: AbortSignal): Promise<void>;
13
+ /** An AbortController that also aborts when the parent signal does. */
14
+ export declare function childController(parent: AbortSignal): AbortController;
15
+ /** Whether a status means the key itself is refused (unknown, revoked, or the wrong kind). */
16
+ export declare function isFatalStatus(status: number): boolean;
@@ -0,0 +1,53 @@
1
+ // Small helpers both providers share: the default endpoint, Retry-After, backoff and abortable sleep.
2
+ export const DEFAULT_ENDPOINT = "https://flags.ezghcloud.com";
3
+ export function trimEndpoint(endpoint) {
4
+ return (endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
5
+ }
6
+ /** A Retry-After header (seconds or an HTTP date) in milliseconds, or undefined. */
7
+ export function retryAfterMs(res, now = Date.now()) {
8
+ const h = res.headers.get("retry-after");
9
+ if (h === null)
10
+ return undefined;
11
+ const trimmed = h.trim();
12
+ if (/^\d+$/.test(trimmed))
13
+ return Number(trimmed) * 1000;
14
+ const at = Date.parse(trimmed);
15
+ return Number.isNaN(at) ? undefined : Math.max(0, at - now);
16
+ }
17
+ /**
18
+ * Exponential backoff with jitter: attempt 1 waits between min/2 and min, doubling up to max, and
19
+ * never less than the server's Retry-After, itself capped at max (a broken or hostile Retry-After
20
+ * can't park a provider for hours).
21
+ */
22
+ export function backoffMs(attempt, minMs, maxMs, retryAfter) {
23
+ const base = Math.min(maxMs, minMs * 2 ** Math.max(0, attempt - 1));
24
+ const jittered = base / 2 + (Math.random() * base) / 2;
25
+ return Math.max(jittered, Math.min(retryAfter ?? 0, maxMs));
26
+ }
27
+ /** Resolves after ms, or at once when the signal aborts. */
28
+ export function sleep(ms, signal) {
29
+ return new Promise((resolve) => {
30
+ if (signal.aborted)
31
+ return resolve();
32
+ const done = () => {
33
+ clearTimeout(t);
34
+ signal.removeEventListener("abort", done);
35
+ resolve();
36
+ };
37
+ const t = setTimeout(done, ms);
38
+ signal.addEventListener("abort", done, { once: true });
39
+ });
40
+ }
41
+ /** An AbortController that also aborts when the parent signal does. */
42
+ export function childController(parent) {
43
+ const c = new AbortController();
44
+ if (parent.aborted)
45
+ c.abort();
46
+ else
47
+ parent.addEventListener("abort", () => c.abort(), { once: true, signal: c.signal });
48
+ return c;
49
+ }
50
+ /** Whether a status means the key itself is refused (unknown, revoked, or the wrong kind). */
51
+ export function isFatalStatus(status) {
52
+ return status === 401 || status === 403;
53
+ }
@@ -0,0 +1,16 @@
1
+ export interface SSEEvent {
2
+ event: string;
3
+ id?: string;
4
+ data: string;
5
+ }
6
+ export declare function readSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent>;
7
+ /**
8
+ * readSSE over a stream that must not go silent: any byte (a heartbeat comment included) counts
9
+ * as activity, and after `idleMs` without any the controller is aborted, ending the read. Each
10
+ * chunk also calls `onActivity`.
11
+ */
12
+ export declare function watchedSSE(body: ReadableStream<Uint8Array>, ctl: AbortController, idleMs: number, onActivity?: () => void): AsyncGenerator<SSEEvent>;
13
+ /** The reason of an `end` event (`{"reason": "rotate"}`), or "unknown". */
14
+ export declare function endReason(data: string): string;
15
+ /** Whether a response is an event stream (Content-Type text/event-stream). */
16
+ export declare function isEventStream(res: Response): boolean;