@ezghcloud/flags-openfeature 0.0.0-stage → 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/LICENSE +201 -0
- package/README.md +167 -2
- package/dist/server.d.ts +42 -0
- package/dist/server.js +398 -0
- package/dist/shared/evaluator.d.ts +33 -0
- package/dist/shared/evaluator.js +111 -0
- package/dist/shared/http.d.ts +16 -0
- package/dist/shared/http.js +53 -0
- package/dist/shared/sse.d.ts +16 -0
- package/dist/shared/sse.js +128 -0
- package/dist/web.d.ts +44 -0
- package/dist/web.js +503 -0
- package/package.json +63 -4
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A server-sent events parser over a fetch body, following the WHATWG event-stream rules: lines
|
|
2
|
+
// end with LF, CRLF or CR; comments (lines starting with ":") are ignored; `data` lines are joined
|
|
3
|
+
// with "\n"; a frame dispatches on a blank line if it had data; the last event ID persists across
|
|
4
|
+
// frames; `event` defaults to "message". EventSource can't send a POST body or an Authorization
|
|
5
|
+
// header, so the providers read streams with fetch and this.
|
|
6
|
+
export async function* readSSE(body) {
|
|
7
|
+
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
|
|
8
|
+
// A line still waiting for its ending, in pieces: each chunk is scanned once, so a long line
|
|
9
|
+
// (a whole snapshot is one `data:` line) costs linear time, not a rescan per chunk.
|
|
10
|
+
let partial = [];
|
|
11
|
+
let first = true;
|
|
12
|
+
let skipLF = false; // the last chunk ended with a CR: an LF starting the next one is its pair
|
|
13
|
+
let event = "";
|
|
14
|
+
let data = [];
|
|
15
|
+
let lastId;
|
|
16
|
+
const ending = /[\r\n]/g;
|
|
17
|
+
try {
|
|
18
|
+
for (;;) {
|
|
19
|
+
const { value, done } = await reader.read();
|
|
20
|
+
if (done)
|
|
21
|
+
return; // an unfinished line or frame at EOF dispatches nothing
|
|
22
|
+
let chunk = value;
|
|
23
|
+
if (first && chunk.length > 0) {
|
|
24
|
+
if (chunk.charCodeAt(0) === 0xfeff)
|
|
25
|
+
chunk = chunk.slice(1);
|
|
26
|
+
first = false;
|
|
27
|
+
}
|
|
28
|
+
let i = 0;
|
|
29
|
+
if (skipLF && chunk.length > 0) {
|
|
30
|
+
if (chunk.charCodeAt(0) === 10)
|
|
31
|
+
i = 1;
|
|
32
|
+
skipLF = false;
|
|
33
|
+
}
|
|
34
|
+
for (;;) {
|
|
35
|
+
ending.lastIndex = i;
|
|
36
|
+
const m = ending.exec(chunk);
|
|
37
|
+
if (!m) {
|
|
38
|
+
if (i < chunk.length)
|
|
39
|
+
partial.push(chunk.slice(i));
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
const j = m.index;
|
|
43
|
+
let line = chunk.slice(i, j);
|
|
44
|
+
if (partial.length > 0) {
|
|
45
|
+
partial.push(line);
|
|
46
|
+
line = partial.join("");
|
|
47
|
+
partial = [];
|
|
48
|
+
}
|
|
49
|
+
i = j + 1;
|
|
50
|
+
if (chunk.charCodeAt(j) === 13) {
|
|
51
|
+
if (i < chunk.length) {
|
|
52
|
+
if (chunk.charCodeAt(i) === 10)
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
skipLF = true;
|
|
57
|
+
}
|
|
58
|
+
if (line === "") {
|
|
59
|
+
if (data.length > 0) {
|
|
60
|
+
const ev = { event: event || "message", data: data.join("\n") };
|
|
61
|
+
if (lastId !== undefined)
|
|
62
|
+
ev.id = lastId;
|
|
63
|
+
yield ev;
|
|
64
|
+
}
|
|
65
|
+
event = "";
|
|
66
|
+
data = [];
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (line.charCodeAt(0) === 58)
|
|
70
|
+
continue; // ":" comment
|
|
71
|
+
const colon = line.indexOf(":");
|
|
72
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
73
|
+
let val = colon < 0 ? "" : line.slice(colon + 1);
|
|
74
|
+
if (val.charCodeAt(0) === 32)
|
|
75
|
+
val = val.slice(1);
|
|
76
|
+
if (field === "event")
|
|
77
|
+
event = val;
|
|
78
|
+
else if (field === "data")
|
|
79
|
+
data.push(val);
|
|
80
|
+
else if (field === "id" && !val.includes("\0"))
|
|
81
|
+
lastId = val;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await reader.cancel().catch(() => { });
|
|
87
|
+
reader.releaseLock();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* readSSE over a stream that must not go silent: any byte (a heartbeat comment included) counts
|
|
92
|
+
* as activity, and after `idleMs` without any the controller is aborted, ending the read. Each
|
|
93
|
+
* chunk also calls `onActivity`.
|
|
94
|
+
*/
|
|
95
|
+
export async function* watchedSSE(body, ctl, idleMs, onActivity = () => { }) {
|
|
96
|
+
let last = Date.now();
|
|
97
|
+
const idle = setInterval(() => {
|
|
98
|
+
if (Date.now() - last > idleMs)
|
|
99
|
+
ctl.abort();
|
|
100
|
+
}, Math.max(10, Math.min(idleMs / 3, 5_000)));
|
|
101
|
+
const watched = body.pipeThrough(new TransformStream({
|
|
102
|
+
transform(chunk, c) {
|
|
103
|
+
last = Date.now();
|
|
104
|
+
onActivity();
|
|
105
|
+
c.enqueue(chunk);
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
108
|
+
try {
|
|
109
|
+
yield* readSSE(watched);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
clearInterval(idle);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The reason of an `end` event (`{"reason": "rotate"}`), or "unknown". */
|
|
116
|
+
export function endReason(data) {
|
|
117
|
+
try {
|
|
118
|
+
const v = JSON.parse(data);
|
|
119
|
+
return typeof v.reason === "string" ? v.reason : "unknown";
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return "unknown";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Whether a response is an event stream (Content-Type text/event-stream). */
|
|
126
|
+
export function isEventStream(res) {
|
|
127
|
+
return (res.headers.get("content-type") ?? "").toLowerCase().startsWith("text/event-stream");
|
|
128
|
+
}
|
package/dist/web.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { OpenFeatureEventEmitter, type EvaluationContext, type JsonValue, type Logger, type Provider, type ResolutionDetails } from "@openfeature/web-sdk";
|
|
2
|
+
export { DEFAULT_ENDPOINT } from "./shared/http.ts";
|
|
3
|
+
export interface EzghWebProviderOptions {
|
|
4
|
+
/** A client SDK key (`ezgh_fc_…`). It's publishable: it sees only `clientSide` flags. */
|
|
5
|
+
clientKey: string;
|
|
6
|
+
/** The data plane; defaults to https://flags.ezghcloud.com. */
|
|
7
|
+
endpoint?: string;
|
|
8
|
+
/** Follow the client change stream and refetch on every change (default false: one connection per open tab). */
|
|
9
|
+
live?: boolean;
|
|
10
|
+
/** The periodic refresh interval (default 5 minutes). */
|
|
11
|
+
refreshMs?: number;
|
|
12
|
+
/** The fetch to use; defaults to the global one. */
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
/** Logs connection problems; defaults to console warnings and errors. */
|
|
15
|
+
logger?: Logger;
|
|
16
|
+
/** How long `initialize` retries the first evaluation (default 10 s). Retrying continues after. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** Retry backoff bounds (default 1 s to 60 s, with jitter). */
|
|
19
|
+
minBackoffMs?: number;
|
|
20
|
+
maxBackoffMs?: number;
|
|
21
|
+
/** A live stream silent this long (heartbeats come every 15 s) is reconnected (default 45 s). */
|
|
22
|
+
idleTimeoutMs?: number;
|
|
23
|
+
/**
|
|
24
|
+
* After a `changed` whose refetch answers 304 (the pod answering may not have the new version
|
|
25
|
+
* yet), refetch once more after this long (default 1 s).
|
|
26
|
+
*/
|
|
27
|
+
changedRetryMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class EzghWebProvider implements Provider {
|
|
30
|
+
#private;
|
|
31
|
+
readonly metadata: {
|
|
32
|
+
readonly name: "ezgh-flags";
|
|
33
|
+
};
|
|
34
|
+
readonly runsOn: "client";
|
|
35
|
+
readonly events: OpenFeatureEventEmitter;
|
|
36
|
+
constructor(options: EzghWebProviderOptions);
|
|
37
|
+
initialize(context?: EvaluationContext): Promise<void>;
|
|
38
|
+
onContextChange(_old: EvaluationContext, next: EvaluationContext): Promise<void>;
|
|
39
|
+
onClose(): Promise<void>;
|
|
40
|
+
resolveBooleanEvaluation(flagKey: string, defaultValue: boolean): ResolutionDetails<boolean>;
|
|
41
|
+
resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails<string>;
|
|
42
|
+
resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails<number>;
|
|
43
|
+
resolveObjectEvaluation<T extends JsonValue>(flagKey: string, defaultValue: T): ResolutionDetails<T>;
|
|
44
|
+
}
|
package/dist/web.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// EzghWebProvider: the OpenFeature web provider. Following OpenFeature's static-context paradigm,
|
|
2
|
+
// it runs one bulk OFREP evaluation (POST /ofrep/v1/evaluate/flags) at initialize and on every
|
|
3
|
+
// context change, then answers evaluations synchronously from those results. It refreshes them
|
|
4
|
+
// when the page becomes visible again and every 5 minutes (If-None-Match), and with `live: true`
|
|
5
|
+
// it follows POST /v1/client/stream and refetches on `changed`. A client key only ever sees
|
|
6
|
+
// `clientSide` flags, and rules never reach the browser. The only state it keeps is the current
|
|
7
|
+
// results, in memory: nothing goes to localStorage or sessionStorage.
|
|
8
|
+
import { ErrorCode, GeneralError, InvalidContextError, OpenFeatureEventEmitter, ProviderEvents, ProviderFatalError, } from "@openfeature/web-sdk";
|
|
9
|
+
import { backoffMs, childController, isFatalStatus, retryAfterMs, sleep, trimEndpoint } from "./shared/http.js";
|
|
10
|
+
import { endReason, isEventStream, watchedSSE } from "./shared/sse.js";
|
|
11
|
+
export { DEFAULT_ENDPOINT } from "./shared/http.js";
|
|
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
|
+
class Retryable extends Error {
|
|
19
|
+
retryAfter;
|
|
20
|
+
constructor(message, retryAfter) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.retryAfter = retryAfter;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Errors for a 400: the context itself is refused, so retrying it can't help (final until it
|
|
27
|
+
* changes). A set, not a subclass: OpenFeature's error classes reset their own prototypes, so a
|
|
28
|
+
* subclass wouldn't survive `instanceof`.
|
|
29
|
+
*/
|
|
30
|
+
const finalErrors = new WeakSet();
|
|
31
|
+
function final(e) {
|
|
32
|
+
finalErrors.add(e);
|
|
33
|
+
return e;
|
|
34
|
+
}
|
|
35
|
+
function isFinal(e) {
|
|
36
|
+
return e instanceof Error && finalErrors.has(e);
|
|
37
|
+
}
|
|
38
|
+
const errorCodes = new Set(Object.values(ErrorCode));
|
|
39
|
+
export class EzghWebProvider {
|
|
40
|
+
metadata = { name: "ezgh-flags" };
|
|
41
|
+
runsOn = "client";
|
|
42
|
+
events = new OpenFeatureEventEmitter();
|
|
43
|
+
#key;
|
|
44
|
+
#endpoint;
|
|
45
|
+
#live;
|
|
46
|
+
#refreshMs;
|
|
47
|
+
#fetch;
|
|
48
|
+
#log;
|
|
49
|
+
#timeoutMs;
|
|
50
|
+
#minBackoff;
|
|
51
|
+
#maxBackoff;
|
|
52
|
+
#idleMs;
|
|
53
|
+
#changedRetryMs;
|
|
54
|
+
/** Made by each initialize, so the provider can be initialized again after onClose. */
|
|
55
|
+
#abort = new AbortController();
|
|
56
|
+
#results = null;
|
|
57
|
+
#etag = null;
|
|
58
|
+
#context = {};
|
|
59
|
+
/** Bumped on every context change (and initialize): an answer for an older context is dropped. */
|
|
60
|
+
#gen = 0;
|
|
61
|
+
#fatal = false;
|
|
62
|
+
#init = "pending";
|
|
63
|
+
#recovering = false;
|
|
64
|
+
#inContextChange = false;
|
|
65
|
+
/** The highest client artifact version a `changed` event has named. */
|
|
66
|
+
#lastChanged = 0;
|
|
67
|
+
#refreshing = null;
|
|
68
|
+
#refreshAgain = false;
|
|
69
|
+
#timer;
|
|
70
|
+
#onVisibility = () => {
|
|
71
|
+
if (visibleDocument())
|
|
72
|
+
void this.#refresh();
|
|
73
|
+
};
|
|
74
|
+
constructor(options) {
|
|
75
|
+
if (!options.clientKey?.startsWith("ezgh_fc_")) {
|
|
76
|
+
throw new TypeError("EzghWebProvider needs a client key (ezgh_fc_…); server keys (ezgh_fs_…) are secret and belong in EzghProvider");
|
|
77
|
+
}
|
|
78
|
+
this.#key = options.clientKey;
|
|
79
|
+
this.#endpoint = trimEndpoint(options.endpoint);
|
|
80
|
+
this.#live = options.live ?? false;
|
|
81
|
+
this.#refreshMs = options.refreshMs ?? 5 * 60_000;
|
|
82
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
83
|
+
this.#log = options.logger ?? defaultLogger;
|
|
84
|
+
this.#timeoutMs = options.timeoutMs ?? 10_000;
|
|
85
|
+
this.#minBackoff = options.minBackoffMs ?? 1_000;
|
|
86
|
+
this.#maxBackoff = options.maxBackoffMs ?? 60_000;
|
|
87
|
+
this.#idleMs = options.idleTimeoutMs ?? 45_000;
|
|
88
|
+
this.#changedRetryMs = options.changedRetryMs ?? 1_000;
|
|
89
|
+
}
|
|
90
|
+
async initialize(context) {
|
|
91
|
+
this.#stop();
|
|
92
|
+
this.#abort = new AbortController();
|
|
93
|
+
this.#results = null;
|
|
94
|
+
this.#etag = null;
|
|
95
|
+
this.#gen++;
|
|
96
|
+
this.#fatal = false;
|
|
97
|
+
this.#init = "pending";
|
|
98
|
+
this.#recovering = false;
|
|
99
|
+
this.#lastChanged = 0;
|
|
100
|
+
this.#refreshing = null;
|
|
101
|
+
this.#refreshAgain = false;
|
|
102
|
+
this.#context = context ?? {};
|
|
103
|
+
try {
|
|
104
|
+
await this.#evaluateWithin(this.#timeoutMs);
|
|
105
|
+
this.#init = "done";
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
this.#init = "failed";
|
|
109
|
+
if (!this.#fatal && !isFinal(e))
|
|
110
|
+
this.#recover();
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
if (!this.#fatal)
|
|
115
|
+
this.#startBackground();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async onContextChange(_old, next) {
|
|
119
|
+
this.#context = next;
|
|
120
|
+
this.#gen++;
|
|
121
|
+
this.#etag = null;
|
|
122
|
+
this.#inContextChange = true;
|
|
123
|
+
try {
|
|
124
|
+
// The SDK announces PROVIDER_CONTEXT_CHANGED itself: no CONFIGURATION_CHANGED for this.
|
|
125
|
+
await this.#bulk({ conditional: false, announce: false });
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// The old results describe someone else: answer defaults until this context's arrive.
|
|
129
|
+
this.#results = null;
|
|
130
|
+
if (e instanceof ProviderFatalError) {
|
|
131
|
+
// The SDK marks a failed context change ERROR; FATAL has to follow it.
|
|
132
|
+
setTimeout(() => this.events.emit(ProviderEvents.Error, { errorCode: ErrorCode.PROVIDER_FATAL, message: e.message }), 0);
|
|
133
|
+
}
|
|
134
|
+
else if (!isFinal(e)) {
|
|
135
|
+
this.#recover();
|
|
136
|
+
}
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
this.#inContextChange = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async onClose() {
|
|
144
|
+
this.#stop();
|
|
145
|
+
}
|
|
146
|
+
#stop() {
|
|
147
|
+
this.#abort.abort();
|
|
148
|
+
clearInterval(this.#timer);
|
|
149
|
+
this.#timer = undefined;
|
|
150
|
+
const doc = globalThis.document;
|
|
151
|
+
doc?.removeEventListener?.("visibilitychange", this.#onVisibility);
|
|
152
|
+
}
|
|
153
|
+
resolveBooleanEvaluation(flagKey, defaultValue) {
|
|
154
|
+
return this.#resolve("boolean", flagKey, defaultValue);
|
|
155
|
+
}
|
|
156
|
+
resolveStringEvaluation(flagKey, defaultValue) {
|
|
157
|
+
return this.#resolve("string", flagKey, defaultValue);
|
|
158
|
+
}
|
|
159
|
+
resolveNumberEvaluation(flagKey, defaultValue) {
|
|
160
|
+
return this.#resolve("number", flagKey, defaultValue);
|
|
161
|
+
}
|
|
162
|
+
resolveObjectEvaluation(flagKey, defaultValue) {
|
|
163
|
+
return this.#resolve("object", flagKey, defaultValue);
|
|
164
|
+
}
|
|
165
|
+
#resolve(type, key, defaultValue) {
|
|
166
|
+
if (!this.#results)
|
|
167
|
+
return { value: defaultValue, reason: "ERROR", errorCode: ErrorCode.PROVIDER_NOT_READY };
|
|
168
|
+
const r = this.#results.get(key);
|
|
169
|
+
if (!r)
|
|
170
|
+
return { value: defaultValue, reason: "ERROR", errorCode: ErrorCode.FLAG_NOT_FOUND, errorMessage: `flag '${key}' not found` };
|
|
171
|
+
if (r.errorCode !== undefined) {
|
|
172
|
+
const out = { value: defaultValue, reason: "ERROR", errorCode: errorCodes.has(r.errorCode) ? r.errorCode : ErrorCode.GENERAL };
|
|
173
|
+
if (r.errorDetails !== undefined)
|
|
174
|
+
out.errorMessage = r.errorDetails;
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
const v = r.value;
|
|
178
|
+
const ok = type === "object" ? typeof v === "object" && v !== null : typeof v === type;
|
|
179
|
+
if (!ok) {
|
|
180
|
+
return { value: defaultValue, reason: "ERROR", errorCode: ErrorCode.TYPE_MISMATCH, errorMessage: `flag '${key}' is not a ${type}` };
|
|
181
|
+
}
|
|
182
|
+
const out = { value: v, flagMetadata: flagMetadata(r.metadata) };
|
|
183
|
+
if (r.variant !== undefined)
|
|
184
|
+
out.variant = r.variant;
|
|
185
|
+
if (r.reason !== undefined)
|
|
186
|
+
out.reason = r.reason;
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
// ---- bulk evaluation
|
|
190
|
+
/**
|
|
191
|
+
* One bulk evaluation of the current context. With `conditional`, sends If-None-Match and keeps
|
|
192
|
+
* the results on 304. With `announce`, emits CONFIGURATION_CHANGED for the flags that changed
|
|
193
|
+
* when it replaces results it already had. Throws ProviderFatalError for a refused key, a final
|
|
194
|
+
* error for a refused context (400), and Retryable otherwise.
|
|
195
|
+
*/
|
|
196
|
+
async #bulk(opts) {
|
|
197
|
+
const gen = this.#gen;
|
|
198
|
+
const abort = this.#abort;
|
|
199
|
+
const headers = { Authorization: `Bearer ${this.#key}`, "Content-Type": "application/json" };
|
|
200
|
+
if (opts.conditional && this.#etag)
|
|
201
|
+
headers["If-None-Match"] = this.#etag;
|
|
202
|
+
let res;
|
|
203
|
+
try {
|
|
204
|
+
res = await this.#fetch(`${this.#endpoint}/ofrep/v1/evaluate/flags`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers,
|
|
207
|
+
body: JSON.stringify({ context: this.#context }),
|
|
208
|
+
signal: opts.signal ?? abort.signal,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
throw new Retryable(`bulk evaluation failed: ${e.message}`);
|
|
213
|
+
}
|
|
214
|
+
if (res.status === 304) {
|
|
215
|
+
await res.body?.cancel();
|
|
216
|
+
return "not-modified";
|
|
217
|
+
}
|
|
218
|
+
if (res.status === 400) {
|
|
219
|
+
const body = (await res.json().catch(() => ({})));
|
|
220
|
+
const detail = typeof body.errorDetails === "string" ? `: ${body.errorDetails}` : "";
|
|
221
|
+
if (body.errorCode === "INVALID_CONTEXT")
|
|
222
|
+
throw final(new InvalidContextError(`EzghWebProvider: the data plane refused the context${detail}`));
|
|
223
|
+
throw final(new GeneralError(`EzghWebProvider: bulk evaluation answered 400 (${String(body.errorCode ?? "bad request")})${detail}`));
|
|
224
|
+
}
|
|
225
|
+
if (!res.ok) {
|
|
226
|
+
await res.body?.cancel();
|
|
227
|
+
if (isFatalStatus(res.status))
|
|
228
|
+
throw this.#fatalize(`the data plane refused the client key (${res.status})`);
|
|
229
|
+
throw new Retryable(`bulk evaluation answered ${res.status}`, retryAfterMs(res));
|
|
230
|
+
}
|
|
231
|
+
let flags;
|
|
232
|
+
try {
|
|
233
|
+
const body = (await res.json());
|
|
234
|
+
if (!Array.isArray(body.flags))
|
|
235
|
+
throw new Error("no flags array");
|
|
236
|
+
flags = body.flags.filter((f) => typeof f === "object" && f !== null && typeof f.key === "string");
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
throw new Retryable(`bulk evaluation answered an invalid body: ${e.message}`);
|
|
240
|
+
}
|
|
241
|
+
if (gen !== this.#gen || abort.signal.aborted)
|
|
242
|
+
return "dropped"; // answered for an older context, or closed
|
|
243
|
+
const next = new Map(flags.map((f) => [f.key, f]));
|
|
244
|
+
const prev = this.#results;
|
|
245
|
+
this.#results = next;
|
|
246
|
+
this.#etag = res.headers.get("etag");
|
|
247
|
+
if (prev && opts.announce) {
|
|
248
|
+
const flagsChanged = changed(prev, next);
|
|
249
|
+
if (flagsChanged.length > 0)
|
|
250
|
+
this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged });
|
|
251
|
+
}
|
|
252
|
+
return "ok";
|
|
253
|
+
}
|
|
254
|
+
/** #bulk, retrying retryable failures with backoff until it answers or `ms` pass. */
|
|
255
|
+
async #evaluateWithin(ms) {
|
|
256
|
+
const deadline = Date.now() + ms;
|
|
257
|
+
const ctl = childController(this.#abort.signal);
|
|
258
|
+
const timer = setTimeout(() => ctl.abort(), ms);
|
|
259
|
+
try {
|
|
260
|
+
for (let attempt = 1;; attempt++) {
|
|
261
|
+
try {
|
|
262
|
+
await this.#bulk({ conditional: false, announce: false, signal: ctl.signal });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
if (!(e instanceof Retryable))
|
|
267
|
+
throw e;
|
|
268
|
+
if (ctl.signal.aborted || Date.now() >= deadline)
|
|
269
|
+
throw new GeneralError(`EzghWebProvider: ${e.message}`);
|
|
270
|
+
await sleep(Math.min(backoffMs(attempt, this.#minBackoff, this.#maxBackoff, e.retryAfter), Math.max(0, deadline - Date.now())), ctl.signal);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
ctl.abort();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** After a failed initialize or context change: retry in the background, then announce READY. */
|
|
280
|
+
#recover() {
|
|
281
|
+
if (this.#recovering)
|
|
282
|
+
return;
|
|
283
|
+
this.#recovering = true;
|
|
284
|
+
const signal = this.#abort.signal;
|
|
285
|
+
void (async () => {
|
|
286
|
+
try {
|
|
287
|
+
for (let attempt = 1; !signal.aborted && !this.#fatal; attempt++) {
|
|
288
|
+
const gen = this.#gen;
|
|
289
|
+
try {
|
|
290
|
+
await this.#bulk({ conditional: false, announce: false });
|
|
291
|
+
if (gen === this.#gen && this.#results) {
|
|
292
|
+
this.events.emit(ProviderEvents.Ready);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
attempt = 0;
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
if (!(e instanceof Retryable))
|
|
299
|
+
return;
|
|
300
|
+
await sleep(backoffMs(attempt, this.#minBackoff, this.#maxBackoff, e.retryAfter), signal);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
this.#recovering = false;
|
|
306
|
+
}
|
|
307
|
+
})();
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* A conditional refresh; concurrent calls coalesce into one more after the current one. Answers
|
|
311
|
+
* the last attempt's outcome ("skipped" when there's nothing to refresh, or it failed).
|
|
312
|
+
*/
|
|
313
|
+
#refresh() {
|
|
314
|
+
if (this.#refreshing) {
|
|
315
|
+
this.#refreshAgain = true;
|
|
316
|
+
return this.#refreshing;
|
|
317
|
+
}
|
|
318
|
+
const abort = this.#abort;
|
|
319
|
+
this.#refreshing = (async () => {
|
|
320
|
+
let outcome = "skipped";
|
|
321
|
+
try {
|
|
322
|
+
do {
|
|
323
|
+
this.#refreshAgain = false;
|
|
324
|
+
if (abort.signal.aborted || this.#fatal || !this.#results)
|
|
325
|
+
return "skipped";
|
|
326
|
+
try {
|
|
327
|
+
outcome = await this.#bulk({ conditional: true, announce: true });
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
if (e instanceof Retryable || isFinal(e))
|
|
331
|
+
this.#log.warn(e.message);
|
|
332
|
+
return "skipped";
|
|
333
|
+
}
|
|
334
|
+
} while (this.#refreshAgain);
|
|
335
|
+
return outcome;
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
if (this.#abort === abort)
|
|
339
|
+
this.#refreshing = null;
|
|
340
|
+
}
|
|
341
|
+
})();
|
|
342
|
+
return this.#refreshing;
|
|
343
|
+
}
|
|
344
|
+
/** A `changed` event: refetch; if that's a 304, the answering pod may lag, so once more later. */
|
|
345
|
+
async #onChanged(signal) {
|
|
346
|
+
if ((await this.#refresh()) !== "not-modified")
|
|
347
|
+
return;
|
|
348
|
+
await sleep(this.#changedRetryMs, signal);
|
|
349
|
+
if (!signal.aborted)
|
|
350
|
+
await this.#refresh();
|
|
351
|
+
}
|
|
352
|
+
// ---- refresh triggers and the live stream
|
|
353
|
+
#startBackground() {
|
|
354
|
+
if (this.#timer !== undefined || this.#abort.signal.aborted)
|
|
355
|
+
return;
|
|
356
|
+
this.#timer = setInterval(() => void this.#refresh(), this.#refreshMs);
|
|
357
|
+
const doc = globalThis.document;
|
|
358
|
+
doc?.addEventListener?.("visibilitychange", this.#onVisibility);
|
|
359
|
+
if (this.#live)
|
|
360
|
+
void this.#streamLoop(this.#abort.signal);
|
|
361
|
+
}
|
|
362
|
+
async #streamLoop(signal) {
|
|
363
|
+
let failures = 0;
|
|
364
|
+
while (!signal.aborted && !this.#fatal) {
|
|
365
|
+
let out;
|
|
366
|
+
try {
|
|
367
|
+
out = await this.#streamOnce(signal);
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
// Anything unexpected is a transient failure, never a silent stop.
|
|
371
|
+
this.#log.warn(`client stream: ${e.message}`);
|
|
372
|
+
out = { established: false };
|
|
373
|
+
}
|
|
374
|
+
if (signal.aborted || this.#fatal)
|
|
375
|
+
return;
|
|
376
|
+
if (out.ended === "revoked" || out.ended === "environmentDeleted") {
|
|
377
|
+
this.#fatalize(out.ended === "revoked" ? "the client key was revoked" : "the client key's environment was deleted");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (out.ended === "rotate") {
|
|
381
|
+
failures = 0;
|
|
382
|
+
continue; // the only reason to reconnect at once
|
|
383
|
+
}
|
|
384
|
+
if (out.ended === "shutdown") {
|
|
385
|
+
// The pod is draining: reconnect (to another one) after a little jitter.
|
|
386
|
+
failures = 0;
|
|
387
|
+
await sleep(Math.random() * this.#minBackoff, signal);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (out.ended === undefined && out.established) {
|
|
391
|
+
// Up, then dropped: reconnect soon; it isn't a failed attempt.
|
|
392
|
+
failures = 0;
|
|
393
|
+
await sleep(backoffMs(1, this.#minBackoff, this.#maxBackoff), signal);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
// A failed attempt, or an end with an unknown reason.
|
|
397
|
+
failures++;
|
|
398
|
+
await sleep(backoffMs(failures, this.#minBackoff, this.#maxBackoff, out.retryAfter), signal);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** One client stream connection, from the request until it ends. */
|
|
402
|
+
async #streamOnce(signal) {
|
|
403
|
+
const ctl = childController(signal);
|
|
404
|
+
try {
|
|
405
|
+
let res;
|
|
406
|
+
try {
|
|
407
|
+
res = await this.#fetch(`${this.#endpoint}/v1/client/stream`, {
|
|
408
|
+
method: "POST",
|
|
409
|
+
headers: { Authorization: `Bearer ${this.#key}`, "Content-Type": "application/json", Accept: "text/event-stream" },
|
|
410
|
+
body: "{}",
|
|
411
|
+
signal: ctl.signal,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return { established: false };
|
|
416
|
+
}
|
|
417
|
+
if (!res.ok || !res.body || !isEventStream(res)) {
|
|
418
|
+
await res.body?.cancel();
|
|
419
|
+
if (isFatalStatus(res.status))
|
|
420
|
+
this.#fatalize(`the data plane refused the client key (${res.status})`);
|
|
421
|
+
const retryAfter = retryAfterMs(res);
|
|
422
|
+
return retryAfter === undefined ? { established: false } : { established: false, retryAfter };
|
|
423
|
+
}
|
|
424
|
+
// Connected only once the stream sends its first byte (the opening comment). Every
|
|
425
|
+
// connection, the first included, refreshes (conditionally): a change may have happened
|
|
426
|
+
// while there was no stream, or between the first evaluation and this connection.
|
|
427
|
+
let established = false;
|
|
428
|
+
const onActivity = () => {
|
|
429
|
+
if (established)
|
|
430
|
+
return;
|
|
431
|
+
established = true;
|
|
432
|
+
void this.#refresh();
|
|
433
|
+
};
|
|
434
|
+
try {
|
|
435
|
+
for await (const ev of watchedSSE(res.body, ctl, this.#idleMs, onActivity)) {
|
|
436
|
+
if (ev.event === "changed") {
|
|
437
|
+
const v = changedVersion(ev.data);
|
|
438
|
+
if (v !== undefined && v < this.#lastChanged)
|
|
439
|
+
continue; // an older version, from a lagging pod
|
|
440
|
+
if (v !== undefined)
|
|
441
|
+
this.#lastChanged = v;
|
|
442
|
+
void this.#onChanged(signal);
|
|
443
|
+
}
|
|
444
|
+
else if (ev.event === "end")
|
|
445
|
+
return { established, ended: endReason(ev.data) };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// dropped: the caller reconnects
|
|
450
|
+
}
|
|
451
|
+
return { established };
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
ctl.abort();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** The key was refused: stop everything. Answers the error to throw. */
|
|
458
|
+
#fatalize(message) {
|
|
459
|
+
const err = new ProviderFatalError(`EzghWebProvider: ${message}`);
|
|
460
|
+
if (this.#fatal)
|
|
461
|
+
return err;
|
|
462
|
+
this.#fatal = true;
|
|
463
|
+
this.#log.error(err.message);
|
|
464
|
+
void this.onClose();
|
|
465
|
+
if (this.#init !== "pending" && !this.#inContextChange)
|
|
466
|
+
this.events.emit(ProviderEvents.Error, { errorCode: ErrorCode.PROVIDER_FATAL, message: err.message });
|
|
467
|
+
return err;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function changedVersion(data) {
|
|
471
|
+
try {
|
|
472
|
+
const v = JSON.parse(data).version;
|
|
473
|
+
return typeof v === "number" ? v : undefined;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function visibleDocument() {
|
|
480
|
+
const doc = globalThis.document;
|
|
481
|
+
return doc?.visibilityState === "visible";
|
|
482
|
+
}
|
|
483
|
+
function flagMetadata(m) {
|
|
484
|
+
const out = {};
|
|
485
|
+
for (const [k, v] of Object.entries(m ?? {}))
|
|
486
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
487
|
+
out[k] = v;
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
490
|
+
/** Keys whose value, variant or error changed, appeared or disappeared, sorted. */
|
|
491
|
+
function changed(before, after) {
|
|
492
|
+
const sig = (e) => JSON.stringify([e.value, e.variant, e.errorCode]);
|
|
493
|
+
const out = [];
|
|
494
|
+
for (const [k, v] of after) {
|
|
495
|
+
const b = before.get(k);
|
|
496
|
+
if (!b || sig(b) !== sig(v))
|
|
497
|
+
out.push(k);
|
|
498
|
+
}
|
|
499
|
+
for (const k of before.keys())
|
|
500
|
+
if (!after.has(k))
|
|
501
|
+
out.push(k);
|
|
502
|
+
return out.sort();
|
|
503
|
+
}
|