@toggld/browser 0.0.0-stage → 0.1.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 +202 -0
- package/README.md +112 -2
- package/dist/client-BQFClvyL.d.ts +232 -0
- package/dist/client-BwTN-sJD.js +742 -0
- package/dist/effect.d.ts +43 -0
- package/dist/effect.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +61 -3
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
// ../spec/src/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* Subprotocol entry carrying the bearer credential on a sync WebSocket
|
|
4
|
+
* upgrade: `toggld.bearer.<token>`. The standard `WebSocket` constructor
|
|
5
|
+
* cannot set an `Authorization` header, so the token rides the
|
|
6
|
+
* `Sec-WebSocket-Protocol` offer instead (a recognized pattern — it stays out
|
|
7
|
+
* of URLs and access logs). Servers accept either this or a real
|
|
8
|
+
* `Authorization: Bearer` header.
|
|
9
|
+
*/
|
|
10
|
+
const SYNC_BEARER_PROTOCOL_PREFIX = "toggld.bearer.";
|
|
11
|
+
/**
|
|
12
|
+
* The evaluation stream: `GET /eval/v1/stream` with a WebSocket upgrade. Unlike
|
|
13
|
+
* the sync stream, it carries evaluated values, never targeting rules, so it
|
|
14
|
+
* accepts public tokens (`ffp_…`) as well as evaluation keys. Authenticate with
|
|
15
|
+
* the {@link SYNC_BEARER_PROTOCOL_PREFIX} subprotocol entry, offered after this
|
|
16
|
+
* protocol name.
|
|
17
|
+
*/
|
|
18
|
+
const EVAL_STREAM_PATH = "/eval/v1/stream";
|
|
19
|
+
const EVAL_STREAM_PROTOCOL = "toggld.eval.v1";
|
|
20
|
+
/**
|
|
21
|
+
* Heartbeat on the evaluation stream: the client sends the text frame
|
|
22
|
+
* {@link STREAM_PING} (not JSON) and the server answers {@link STREAM_PONG}.
|
|
23
|
+
* Without it, a connection that a proxy or a sleeping laptop dropped silently
|
|
24
|
+
* would look open forever. Plain fixed strings, so a Durable Object can answer
|
|
25
|
+
* with an auto-response without waking from hibernation.
|
|
26
|
+
*/
|
|
27
|
+
const STREAM_PING = "ping";
|
|
28
|
+
/** How often the client pings, and how long it waits for the pong before it reconnects. */
|
|
29
|
+
const STREAM_PING_INTERVAL_MS = 3e4;
|
|
30
|
+
const STREAM_PONG_TIMEOUT_MS = 1e4;
|
|
31
|
+
// src/internal/client.ts
|
|
32
|
+
const DEFAULT_ENDPOINT = "https://api.toggld.io";
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
34
|
+
const DEFAULT_READY_TIMEOUT_MS = 5e3;
|
|
35
|
+
const DEFAULT_STALE_AFTER_MS = 6e4;
|
|
36
|
+
const MIN_POLL_INTERVAL_MS = 5e3;
|
|
37
|
+
const RETRY_BASE_MS = 1e3;
|
|
38
|
+
const RETRY_MAX_MS = 6e4;
|
|
39
|
+
const STREAM_RETRY_MAX_MS = 3e4;
|
|
40
|
+
/** After a 4429 close (too many context changes), wait out the server's one-minute window. */
|
|
41
|
+
const RATE_LIMITED_RECONNECT_MS = 61e3;
|
|
42
|
+
/**
|
|
43
|
+
* Connects in a row that close before opening, after which the stream stops
|
|
44
|
+
* reconnecting on its own. A rejected upgrade (401/403/404/400/429) reaches a
|
|
45
|
+
* page only as a 1006 close, and its usual causes do not heal: an older
|
|
46
|
+
* self-hosted server without the stream, a revoked token, or the shared rate
|
|
47
|
+
* limit, which every retry spends for all visitors.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_FAILED_CONNECTS = 3;
|
|
50
|
+
const NORMAL_CLOSURE = 1e3;
|
|
51
|
+
const ABNORMAL_CLOSURE = 1006;
|
|
52
|
+
const isRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
|
|
53
|
+
const describe = (e) => isRecord(e) && typeof e.message === "string" ? e.message : String(e);
|
|
54
|
+
/**
|
|
55
|
+
* Deep-freeze a JSON value. Results are shared between every reader (and React
|
|
56
|
+
* renders), so they must not be mutable. Freezing instead of cloning also keeps
|
|
57
|
+
* an object flag's identity stable across reads.
|
|
58
|
+
*/
|
|
59
|
+
const freeze = (value) => {
|
|
60
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
61
|
+
Object.freeze(value);
|
|
62
|
+
for (const v of Object.values(value)) freeze(v);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
};
|
|
66
|
+
/** Structural equality for JSON values, for context and result comparisons. */
|
|
67
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
68
|
+
/**
|
|
69
|
+
* Exponential backoff from 1 s, capped, with ±20% jitter, so many visitors that
|
|
70
|
+
* failed together do not retry together.
|
|
71
|
+
*/
|
|
72
|
+
const backoff = (attempt, maxMs) => Math.min(maxMs, RETRY_BASE_MS * 2 ** attempt) * (.8 + Math.random() * .4);
|
|
73
|
+
/** `Retry-After` as milliseconds from now: delay-seconds or an HTTP date. */
|
|
74
|
+
const parseRetryAfter = (header) => {
|
|
75
|
+
if (header === null) return void 0;
|
|
76
|
+
const seconds = Number(header);
|
|
77
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
78
|
+
const date = Date.parse(header);
|
|
79
|
+
return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
|
|
80
|
+
};
|
|
81
|
+
const errorDetails = (flagKey, value, errorCode, errorMessage, flagMetadata = {}) => ({
|
|
82
|
+
flagKey,
|
|
83
|
+
value,
|
|
84
|
+
variant: void 0,
|
|
85
|
+
reason: "ERROR",
|
|
86
|
+
errorCode,
|
|
87
|
+
errorMessage,
|
|
88
|
+
flagMetadata
|
|
89
|
+
});
|
|
90
|
+
/** One entry of an OFREP bulk response, or `undefined` when it is malformed. */
|
|
91
|
+
const fromWire = (raw) => {
|
|
92
|
+
if (!isRecord(raw) || typeof raw.key !== "string") return void 0;
|
|
93
|
+
const flagMetadata = freeze(isRecord(raw.metadata) ? raw.metadata : {});
|
|
94
|
+
if (typeof raw.errorCode === "string") return {
|
|
95
|
+
flagKey: raw.key,
|
|
96
|
+
value: null,
|
|
97
|
+
variant: void 0,
|
|
98
|
+
reason: "ERROR",
|
|
99
|
+
errorCode: raw.errorCode,
|
|
100
|
+
errorMessage: typeof raw.errorDetails === "string" ? raw.errorDetails : void 0,
|
|
101
|
+
flagMetadata
|
|
102
|
+
};
|
|
103
|
+
if (!("value" in raw) || typeof raw.reason !== "string") return void 0;
|
|
104
|
+
return {
|
|
105
|
+
flagKey: raw.key,
|
|
106
|
+
value: freeze(raw.value),
|
|
107
|
+
variant: typeof raw.variant === "string" ? raw.variant : void 0,
|
|
108
|
+
reason: raw.reason,
|
|
109
|
+
errorCode: void 0,
|
|
110
|
+
errorMessage: void 0,
|
|
111
|
+
flagMetadata
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* A bootstrap entry. It may have passed through JSON (server-side rendering),
|
|
116
|
+
* which drops `undefined` fields, so every field is optional on the way in.
|
|
117
|
+
*/
|
|
118
|
+
const fromBootstrap = (raw) => {
|
|
119
|
+
if (!isRecord(raw) || typeof raw.flagKey !== "string") return void 0;
|
|
120
|
+
return {
|
|
121
|
+
flagKey: raw.flagKey,
|
|
122
|
+
value: freeze(raw.value ?? null),
|
|
123
|
+
variant: typeof raw.variant === "string" ? raw.variant : void 0,
|
|
124
|
+
reason: typeof raw.reason === "string" ? raw.reason : "UNKNOWN",
|
|
125
|
+
errorCode: typeof raw.errorCode === "string" ? raw.errorCode : void 0,
|
|
126
|
+
errorMessage: typeof raw.errorMessage === "string" ? raw.errorMessage : void 0,
|
|
127
|
+
flagMetadata: freeze(isRecord(raw.flagMetadata) ? { ...raw.flagMetadata } : {})
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
const isPresent = (v) => v !== void 0;
|
|
131
|
+
/** A stable fingerprint of a result, cached per (immutable) result object. */
|
|
132
|
+
const signatures = /* @__PURE__ */ new WeakMap();
|
|
133
|
+
const signature = (d) => {
|
|
134
|
+
let s = signatures.get(d);
|
|
135
|
+
if (s === void 0) {
|
|
136
|
+
s = JSON.stringify([
|
|
137
|
+
d.value,
|
|
138
|
+
d.variant,
|
|
139
|
+
d.reason,
|
|
140
|
+
d.errorCode,
|
|
141
|
+
d.errorMessage,
|
|
142
|
+
d.flagMetadata
|
|
143
|
+
]);
|
|
144
|
+
signatures.set(d, s);
|
|
145
|
+
}
|
|
146
|
+
return s;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Merge fresh results into the previous map with structural sharing: a result
|
|
150
|
+
* that did not change keeps its old object, and when nothing changed the old
|
|
151
|
+
* map itself is returned. Readers that compare by identity see no change.
|
|
152
|
+
*/
|
|
153
|
+
const mergeFlags = (previous, incoming) => {
|
|
154
|
+
const next = /* @__PURE__ */ new Map();
|
|
155
|
+
let changed = previous.size !== incoming.length;
|
|
156
|
+
for (const entry of incoming) {
|
|
157
|
+
const old = previous.get(entry.flagKey);
|
|
158
|
+
if (old !== void 0 && signature(old) === signature(entry)) next.set(entry.flagKey, old);
|
|
159
|
+
else {
|
|
160
|
+
next.set(entry.flagKey, entry);
|
|
161
|
+
changed = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return changed ? next : previous;
|
|
165
|
+
};
|
|
166
|
+
const matches = (value, type) => {
|
|
167
|
+
switch (type) {
|
|
168
|
+
case "boolean": return typeof value === "boolean";
|
|
169
|
+
case "string": return typeof value === "string";
|
|
170
|
+
case "number": return typeof value === "number" && Number.isFinite(value);
|
|
171
|
+
case "object": return typeof value === "object" && value !== null;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* A Toggld client for browser code. Create one per page (outside any React
|
|
176
|
+
* render), with {@link ToggldClient.create} to await the first values or with
|
|
177
|
+
* `new ToggldClient(options)` to start loading in the background.
|
|
178
|
+
*/
|
|
179
|
+
var ToggldClient = class ToggldClient {
|
|
180
|
+
#fetch;
|
|
181
|
+
#url;
|
|
182
|
+
#token;
|
|
183
|
+
#timeoutMs;
|
|
184
|
+
#staleAfterMs;
|
|
185
|
+
#refreshOnVisible;
|
|
186
|
+
/** The WebSocket constructor for the evaluation stream, if it is enabled and available. */
|
|
187
|
+
#webSocket;
|
|
188
|
+
#streamUrl;
|
|
189
|
+
#snapshot;
|
|
190
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
191
|
+
/** Increments on every context change; responses for an older one are dropped. */
|
|
192
|
+
#generation = 0;
|
|
193
|
+
#inFlight;
|
|
194
|
+
#retryTimer;
|
|
195
|
+
#retryAttempt = 0;
|
|
196
|
+
#pollTimer;
|
|
197
|
+
/** No request before this time (`Date.now()`), after a 429. */
|
|
198
|
+
#blockedUntil = 0;
|
|
199
|
+
/** The token was rejected (401/403): evaluations report `PROVIDER_FATAL`. */
|
|
200
|
+
#fatal = false;
|
|
201
|
+
/**
|
|
202
|
+
* The last failure was permanent (a rejected token or context), so background
|
|
203
|
+
* refreshes stop until an explicit `refresh()` or a new context.
|
|
204
|
+
*/
|
|
205
|
+
#halted = false;
|
|
206
|
+
#closed = false;
|
|
207
|
+
#socket;
|
|
208
|
+
#reconnectTimer;
|
|
209
|
+
#reconnectAttempt = 0;
|
|
210
|
+
/** The server closed with 1008 (policy), or the socket cannot be built: never reconnect. */
|
|
211
|
+
#streamStopped = false;
|
|
212
|
+
/**
|
|
213
|
+
* Waiting instead of reconnecting: `context` after 1007/1009 (a new context
|
|
214
|
+
* retries), `connect` after too many failed connects (see
|
|
215
|
+
* {@link MAX_FAILED_CONNECTS}; `setContext()` or `refresh()` tries once more).
|
|
216
|
+
*/
|
|
217
|
+
#parked;
|
|
218
|
+
#failedConnects = 0;
|
|
219
|
+
/** The current socket delivered values for the current generation. */
|
|
220
|
+
#socketLive = false;
|
|
221
|
+
/** Adopted pushes, so an HTTP response can tell if one arrived during its flight. */
|
|
222
|
+
#pushes = 0;
|
|
223
|
+
#pingTimer;
|
|
224
|
+
/** Set while a ping awaits its answer. */
|
|
225
|
+
#pongTimer;
|
|
226
|
+
#hiddenAt;
|
|
227
|
+
#readyResolved = false;
|
|
228
|
+
#ready;
|
|
229
|
+
#resolveReady;
|
|
230
|
+
#onVisibilityChange = () => this.#handleVisible();
|
|
231
|
+
constructor(options) {
|
|
232
|
+
if (typeof options.publicToken !== "string" || options.publicToken.length === 0) throw new TypeError("[toggld] `publicToken` is required: the environment's public token (ffp_…).");
|
|
233
|
+
if (options.publicToken.startsWith("ffk_")) console.warn("[toggld] `publicToken` looks like a secret key (ffk_…). Secret keys must never ship to browsers. Use the environment's public token (ffp_…) from Settings → API Keys.");
|
|
234
|
+
const poll = options.pollIntervalMs;
|
|
235
|
+
if (poll !== void 0 && !(Number.isFinite(poll) && poll >= MIN_POLL_INTERVAL_MS)) throw new RangeError(`[toggld] \`pollIntervalMs\` must be a finite number of at least ${MIN_POLL_INTERVAL_MS}.`);
|
|
236
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
237
|
+
this.#fetch = (input, init) => fetchImpl(input, init);
|
|
238
|
+
this.#url = `${(options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "")}/ofrep/v1/evaluate/flags`;
|
|
239
|
+
this.#token = options.publicToken;
|
|
240
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
241
|
+
this.#staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
242
|
+
this.#refreshOnVisible = options.refreshOnVisible ?? true;
|
|
243
|
+
const base = (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
244
|
+
this.#streamUrl = `${base.replace(/^http(s?):\/\//i, "ws$1://")}${EVAL_STREAM_PATH}`;
|
|
245
|
+
const webSocket = options.webSocket ?? (typeof WebSocket === "function" ? WebSocket : void 0);
|
|
246
|
+
this.#webSocket = options.stream !== false && typeof webSocket === "function" && (options.webSocket !== void 0 || typeof document !== "undefined") ? webSocket : void 0;
|
|
247
|
+
this.#ready = new Promise((resolve) => {
|
|
248
|
+
this.#resolveReady = resolve;
|
|
249
|
+
});
|
|
250
|
+
const bootstrap = (options.bootstrap ?? []).map(fromBootstrap).filter(isPresent);
|
|
251
|
+
const context = freeze({ ...options.context });
|
|
252
|
+
this.#snapshot = bootstrap.length > 0 ? {
|
|
253
|
+
status: "ready",
|
|
254
|
+
context,
|
|
255
|
+
flags: mergeFlags(/* @__PURE__ */ new Map(), bootstrap),
|
|
256
|
+
error: void 0,
|
|
257
|
+
updatedAt: Date.now(),
|
|
258
|
+
streaming: false
|
|
259
|
+
} : {
|
|
260
|
+
status: "loading",
|
|
261
|
+
context,
|
|
262
|
+
flags: /* @__PURE__ */ new Map(),
|
|
263
|
+
error: void 0,
|
|
264
|
+
updatedAt: void 0,
|
|
265
|
+
streaming: false
|
|
266
|
+
};
|
|
267
|
+
if (bootstrap.length > 0) this.#markReady();
|
|
268
|
+
else this.#request();
|
|
269
|
+
if (typeof document !== "undefined") {
|
|
270
|
+
document.addEventListener("visibilitychange", this.#onVisibilityChange);
|
|
271
|
+
if (poll !== void 0) this.#pollTimer = setInterval(() => this.#poll(), poll);
|
|
272
|
+
}
|
|
273
|
+
this.#connect();
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Create a client and wait for its first values, or for `readyTimeoutMs`
|
|
277
|
+
* (default 5 s), whichever is first. It never rejects: on a timeout or a
|
|
278
|
+
* failure the client serves caller defaults and keeps retrying.
|
|
279
|
+
*/
|
|
280
|
+
static async create(options) {
|
|
281
|
+
const client = new ToggldClient(options);
|
|
282
|
+
let timer;
|
|
283
|
+
await Promise.race([client.ready(), new Promise((resolve) => {
|
|
284
|
+
timer = setTimeout(resolve, options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
|
285
|
+
})]);
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
return client;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Resolves once the client first has values for its context, or once a
|
|
291
|
+
* failure makes that impossible (a rejected token or context). It never
|
|
292
|
+
* rejects. A client built with a bootstrap is ready at once.
|
|
293
|
+
*/
|
|
294
|
+
ready() {
|
|
295
|
+
return this.#ready;
|
|
296
|
+
}
|
|
297
|
+
/** The current {@link ClientSnapshot}: the same object until something changes. */
|
|
298
|
+
getSnapshot = () => this.#snapshot;
|
|
299
|
+
/**
|
|
300
|
+
* Call `listener` after every state change: new values, a status change, or
|
|
301
|
+
* a new context. Returns a function that removes it. The signature matches
|
|
302
|
+
* React's `useSyncExternalStore`, so `client.subscribe` can be passed as is.
|
|
303
|
+
*/
|
|
304
|
+
subscribe = (listener) => {
|
|
305
|
+
this.#listeners.add(listener);
|
|
306
|
+
return () => {
|
|
307
|
+
this.#listeners.delete(listener);
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
get status() {
|
|
311
|
+
return this.#snapshot.status;
|
|
312
|
+
}
|
|
313
|
+
get context() {
|
|
314
|
+
return this.#snapshot.context;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Evaluate for a new context: for example after sign-in, or when an
|
|
318
|
+
* attribute that targeting rules use changes. The context is **replaced**,
|
|
319
|
+
* not merged. Until the new values arrive the status is `"reconciling"` and
|
|
320
|
+
* the previous values are still served. Resolves when the request settles; it
|
|
321
|
+
* never rejects. Setting an identical context does nothing. An open stream
|
|
322
|
+
* receives the new context too.
|
|
323
|
+
*/
|
|
324
|
+
setContext(context) {
|
|
325
|
+
if (this.#closed) return Promise.resolve();
|
|
326
|
+
if (sameJson(context, this.#snapshot.context)) return this.#inFlight?.promise ?? Promise.resolve();
|
|
327
|
+
this.#generation++;
|
|
328
|
+
this.#socketLive = false;
|
|
329
|
+
this.#clearRetry();
|
|
330
|
+
this.#halted = false;
|
|
331
|
+
const hasValues = this.#snapshot.updatedAt !== void 0;
|
|
332
|
+
this.#update({
|
|
333
|
+
context: freeze({ ...context }),
|
|
334
|
+
status: hasValues ? "reconciling" : "loading"
|
|
335
|
+
});
|
|
336
|
+
if (this.#snapshot.streaming) this.#sendContext();
|
|
337
|
+
else if (this.#parked !== void 0) this.#unpark();
|
|
338
|
+
return this.#request();
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Fetch fresh values for the current context now. A request already in
|
|
342
|
+
* flight for it is shared rather than repeated. Resolves when it settles; it
|
|
343
|
+
* never rejects. After a 429 it waits for `Retry-After` first. While the
|
|
344
|
+
* stream is live it does nothing: the values are already current.
|
|
345
|
+
*/
|
|
346
|
+
refresh() {
|
|
347
|
+
if (this.#closed) return Promise.resolve();
|
|
348
|
+
if (this.#live()) return this.#inFlight?.promise ?? Promise.resolve();
|
|
349
|
+
this.#halted = false;
|
|
350
|
+
if (this.#parked === "connect") this.#unpark();
|
|
351
|
+
return this.#request();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Stop all background work (requests, retries, polling, the stream,
|
|
355
|
+
* listeners). Values stay readable, but they no longer update.
|
|
356
|
+
*/
|
|
357
|
+
close() {
|
|
358
|
+
if (this.#closed) return;
|
|
359
|
+
this.#closed = true;
|
|
360
|
+
this.#inFlight?.controller.abort();
|
|
361
|
+
this.#inFlight = void 0;
|
|
362
|
+
this.#clearRetry();
|
|
363
|
+
if (this.#pollTimer !== void 0) clearInterval(this.#pollTimer);
|
|
364
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", this.#onVisibilityChange);
|
|
365
|
+
this.#listeners.clear();
|
|
366
|
+
this.#teardown();
|
|
367
|
+
this.#markReady();
|
|
368
|
+
}
|
|
369
|
+
/** Resolve a flag, returning full details with an untyped (JSON) value. */
|
|
370
|
+
evaluate(key, defaultValue) {
|
|
371
|
+
const snapshot = this.#snapshot;
|
|
372
|
+
const entry = snapshot.flags.get(key);
|
|
373
|
+
if (entry === void 0) {
|
|
374
|
+
if (snapshot.updatedAt !== void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
|
|
375
|
+
return this.#fatal ? errorDetails(key, defaultValue, "PROVIDER_FATAL", snapshot.error ?? "The client cannot evaluate flags") : errorDetails(key, defaultValue, "PROVIDER_NOT_READY", snapshot.error ?? "Flag values are not loaded yet");
|
|
376
|
+
}
|
|
377
|
+
if (entry.errorCode !== void 0) return errorDetails(key, defaultValue, entry.errorCode, entry.errorMessage ?? "", entry.flagMetadata);
|
|
378
|
+
return entry;
|
|
379
|
+
}
|
|
380
|
+
/** The last result of every flag for the current context. */
|
|
381
|
+
allFlags() {
|
|
382
|
+
return Array.from(this.#snapshot.flags.values());
|
|
383
|
+
}
|
|
384
|
+
getBooleanValue(key, defaultValue) {
|
|
385
|
+
return this.getBooleanDetails(key, defaultValue).value;
|
|
386
|
+
}
|
|
387
|
+
getBooleanDetails(key, defaultValue) {
|
|
388
|
+
return this.#typed(key, defaultValue, "boolean");
|
|
389
|
+
}
|
|
390
|
+
getStringValue(key, defaultValue) {
|
|
391
|
+
return this.getStringDetails(key, defaultValue).value;
|
|
392
|
+
}
|
|
393
|
+
getStringDetails(key, defaultValue) {
|
|
394
|
+
return this.#typed(key, defaultValue, "string");
|
|
395
|
+
}
|
|
396
|
+
getNumberValue(key, defaultValue) {
|
|
397
|
+
return this.getNumberDetails(key, defaultValue).value;
|
|
398
|
+
}
|
|
399
|
+
getNumberDetails(key, defaultValue) {
|
|
400
|
+
return this.#typed(key, defaultValue, "number");
|
|
401
|
+
}
|
|
402
|
+
/** Object values are deep-frozen and shared: do not mutate them. */
|
|
403
|
+
getObjectValue(key, defaultValue) {
|
|
404
|
+
return this.getObjectDetails(key, defaultValue).value;
|
|
405
|
+
}
|
|
406
|
+
getObjectDetails(key, defaultValue) {
|
|
407
|
+
return this.#typed(key, defaultValue, "object");
|
|
408
|
+
}
|
|
409
|
+
#typed(key, defaultValue, type) {
|
|
410
|
+
const details = this.evaluate(key, defaultValue);
|
|
411
|
+
if (details.errorCode !== void 0 || matches(details.value, type)) return details;
|
|
412
|
+
return errorDetails(key, defaultValue, "TYPE_MISMATCH", `Flag '${key}' is not a ${type} flag`, details.flagMetadata);
|
|
413
|
+
}
|
|
414
|
+
#update(patch) {
|
|
415
|
+
const prev = this.#snapshot;
|
|
416
|
+
const next = {
|
|
417
|
+
...prev,
|
|
418
|
+
...patch
|
|
419
|
+
};
|
|
420
|
+
if (next.status === prev.status && next.context === prev.context && next.flags === prev.flags && next.error === prev.error && next.updatedAt === prev.updatedAt && next.streaming === prev.streaming) return;
|
|
421
|
+
this.#snapshot = next;
|
|
422
|
+
for (const listener of [...this.#listeners]) try {
|
|
423
|
+
listener();
|
|
424
|
+
} catch (e) {
|
|
425
|
+
console.error("[toggld] A subscriber threw:", e);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
#markReady() {
|
|
429
|
+
if (this.#readyResolved) return;
|
|
430
|
+
this.#readyResolved = true;
|
|
431
|
+
this.#resolveReady();
|
|
432
|
+
}
|
|
433
|
+
/** Start (or join) the request for the current context. */
|
|
434
|
+
#request() {
|
|
435
|
+
const generation = this.#generation;
|
|
436
|
+
if (this.#inFlight?.generation === generation) return this.#inFlight.promise;
|
|
437
|
+
this.#inFlight?.controller.abort();
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
const pushes = this.#pushes;
|
|
440
|
+
const promise = this.#fetchAll(this.#snapshot.context, controller.signal).then((outcome) => {
|
|
441
|
+
if (this.#inFlight?.controller === controller) this.#inFlight = void 0;
|
|
442
|
+
this.#settle(generation, outcome, pushes);
|
|
443
|
+
});
|
|
444
|
+
this.#inFlight = {
|
|
445
|
+
generation,
|
|
446
|
+
controller,
|
|
447
|
+
promise
|
|
448
|
+
};
|
|
449
|
+
return promise;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Apply a finished HTTP request (`pushes`: the push count when it started).
|
|
453
|
+
* HTTP may come from a cache that lags the stream, so it must not overwrite
|
|
454
|
+
* pushed values: it is dropped if a push for its context arrived during its
|
|
455
|
+
* flight, or if the stream is live. (A request started after the stream
|
|
456
|
+
* dropped can still briefly return older cached values; fixing that needs an
|
|
457
|
+
* ordered version on both channels.)
|
|
458
|
+
*/
|
|
459
|
+
#settle(generation, outcome, pushes) {
|
|
460
|
+
if (this.#closed || generation !== this.#generation || outcome.kind === "aborted") return;
|
|
461
|
+
if (pushes !== this.#pushes || this.#live()) return;
|
|
462
|
+
if (outcome.kind === "ok") {
|
|
463
|
+
this.#adopt(outcome.flags);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (outcome.retryAfterMs !== void 0) this.#blockedUntil = Math.max(this.#blockedUntil, Date.now() + outcome.retryAfterMs);
|
|
467
|
+
if (outcome.kind !== "retry") this.#halted = true;
|
|
468
|
+
if (outcome.kind === "fatal") {
|
|
469
|
+
this.#fatal = true;
|
|
470
|
+
this.#teardown();
|
|
471
|
+
}
|
|
472
|
+
if (this.#snapshot.status === "ready") {
|
|
473
|
+
this.#update({ error: outcome.message });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.#update({
|
|
477
|
+
status: "error",
|
|
478
|
+
error: outcome.message
|
|
479
|
+
});
|
|
480
|
+
if (outcome.kind === "retry") this.#scheduleRetry();
|
|
481
|
+
else this.#markReady();
|
|
482
|
+
}
|
|
483
|
+
/** Take fresh values for the current context, from HTTP or the stream. */
|
|
484
|
+
#adopt(flags) {
|
|
485
|
+
this.#clearRetry();
|
|
486
|
+
this.#halted = false;
|
|
487
|
+
if (this.#fatal) {
|
|
488
|
+
this.#fatal = false;
|
|
489
|
+
this.#connect();
|
|
490
|
+
}
|
|
491
|
+
this.#update({
|
|
492
|
+
status: "ready",
|
|
493
|
+
flags: mergeFlags(this.#snapshot.flags, flags),
|
|
494
|
+
error: void 0,
|
|
495
|
+
updatedAt: Date.now()
|
|
496
|
+
});
|
|
497
|
+
this.#markReady();
|
|
498
|
+
}
|
|
499
|
+
#scheduleRetry() {
|
|
500
|
+
this.#clearRetry();
|
|
501
|
+
const delay = backoff(this.#retryAttempt, RETRY_MAX_MS);
|
|
502
|
+
this.#retryAttempt++;
|
|
503
|
+
this.#retryTimer = setTimeout(() => {
|
|
504
|
+
this.#retryTimer = void 0;
|
|
505
|
+
this.#request();
|
|
506
|
+
}, delay);
|
|
507
|
+
}
|
|
508
|
+
#clearRetry() {
|
|
509
|
+
if (this.#retryTimer !== void 0) clearTimeout(this.#retryTimer);
|
|
510
|
+
this.#retryTimer = void 0;
|
|
511
|
+
this.#retryAttempt = 0;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Open, and this socket delivered values for the current context: a socket
|
|
515
|
+
* that never answers must not stop polling.
|
|
516
|
+
*/
|
|
517
|
+
#live() {
|
|
518
|
+
return this.#snapshot.streaming && this.#socketLive;
|
|
519
|
+
}
|
|
520
|
+
/** May a background trigger (poll, visibility) send a request now? */
|
|
521
|
+
#backgroundAllowed() {
|
|
522
|
+
return !this.#closed && !this.#halted && !this.#live() && this.#retryTimer === void 0 && Date.now() >= this.#blockedUntil && (typeof document === "undefined" || document.visibilityState !== "hidden");
|
|
523
|
+
}
|
|
524
|
+
#poll() {
|
|
525
|
+
if (this.#backgroundAllowed()) this.#request();
|
|
526
|
+
}
|
|
527
|
+
#handleVisible() {
|
|
528
|
+
if (document.visibilityState === "hidden") {
|
|
529
|
+
this.#hiddenAt = Date.now();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const hiddenFor = this.#hiddenAt === void 0 ? 0 : Date.now() - this.#hiddenAt;
|
|
533
|
+
this.#hiddenAt = void 0;
|
|
534
|
+
if (this.#snapshot.streaming && hiddenFor >= this.#staleAfterMs) this.#ping();
|
|
535
|
+
this.#refreshIfStale();
|
|
536
|
+
}
|
|
537
|
+
#refreshIfStale() {
|
|
538
|
+
if (!this.#refreshOnVisible || !this.#backgroundAllowed()) return;
|
|
539
|
+
const { updatedAt } = this.#snapshot;
|
|
540
|
+
if (updatedAt !== void 0 && Date.now() - updatedAt >= this.#staleAfterMs) this.#request();
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Open the evaluation stream, unless it is off, open, or stopped. Handlers
|
|
544
|
+
* are guarded and ignore stale sockets: a socket never breaks evaluation.
|
|
545
|
+
*/
|
|
546
|
+
#connect() {
|
|
547
|
+
clearTimeout(this.#reconnectTimer);
|
|
548
|
+
this.#reconnectTimer = void 0;
|
|
549
|
+
const WebSocketImpl = this.#webSocket;
|
|
550
|
+
if (WebSocketImpl === void 0 || this.#closed || this.#streamStopped || this.#fatal || this.#socket !== void 0) return;
|
|
551
|
+
let socket;
|
|
552
|
+
try {
|
|
553
|
+
socket = new WebSocketImpl(this.#streamUrl, [EVAL_STREAM_PROTOCOL, SYNC_BEARER_PROTOCOL_PREFIX + this.#token]);
|
|
554
|
+
} catch {
|
|
555
|
+
this.#streamStopped = true;
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
this.#socket = socket;
|
|
559
|
+
this.#socketLive = false;
|
|
560
|
+
let opened = false;
|
|
561
|
+
const guard = (f) => (...args) => {
|
|
562
|
+
if (this.#socket !== socket) return;
|
|
563
|
+
try {
|
|
564
|
+
f(...args);
|
|
565
|
+
} catch (e) {
|
|
566
|
+
console.error("[toggld] The evaluation stream failed:", e);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
socket.onopen = guard(() => {
|
|
570
|
+
opened = true;
|
|
571
|
+
this.#failedConnects = 0;
|
|
572
|
+
this.#pingTimer = setInterval(() => this.#ping(), STREAM_PING_INTERVAL_MS);
|
|
573
|
+
this.#update({ streaming: true });
|
|
574
|
+
this.#sendContext();
|
|
575
|
+
});
|
|
576
|
+
socket.onmessage = guard((event) => this.#receive(event.data));
|
|
577
|
+
socket.onclose = guard((event) => this.#disconnected(event.code, opened));
|
|
578
|
+
}
|
|
579
|
+
#send(data) {
|
|
580
|
+
try {
|
|
581
|
+
this.#socket?.send(data);
|
|
582
|
+
} catch {}
|
|
583
|
+
}
|
|
584
|
+
#sendContext() {
|
|
585
|
+
const message = {
|
|
586
|
+
type: "context",
|
|
587
|
+
seq: this.#generation,
|
|
588
|
+
context: this.#snapshot.context
|
|
589
|
+
};
|
|
590
|
+
this.#send(JSON.stringify(message));
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Send a heartbeat, unless one is waiting. With no frame back in time, the
|
|
594
|
+
* connection died silently (a proxy timeout, a sleep): drop it and reconnect.
|
|
595
|
+
*/
|
|
596
|
+
#ping() {
|
|
597
|
+
if (this.#socket === void 0 || this.#pongTimer !== void 0) return;
|
|
598
|
+
this.#pongTimer = setTimeout(() => {
|
|
599
|
+
this.#pongTimer = void 0;
|
|
600
|
+
this.#disconnected(ABNORMAL_CLOSURE, true);
|
|
601
|
+
this.#refreshIfStale();
|
|
602
|
+
}, STREAM_PONG_TIMEOUT_MS);
|
|
603
|
+
this.#send(STREAM_PING);
|
|
604
|
+
}
|
|
605
|
+
#receive(data) {
|
|
606
|
+
clearTimeout(this.#pongTimer);
|
|
607
|
+
this.#pongTimer = void 0;
|
|
608
|
+
if (typeof data !== "string" || data === "pong") return;
|
|
609
|
+
let message;
|
|
610
|
+
try {
|
|
611
|
+
message = JSON.parse(data);
|
|
612
|
+
} catch {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!isRecord(message) || message.type !== "flags" || typeof message.seq !== "number") return;
|
|
616
|
+
if (!Array.isArray(message.flags)) return;
|
|
617
|
+
this.#reconnectAttempt = 0;
|
|
618
|
+
if (message.seq !== this.#generation) return;
|
|
619
|
+
this.#pushes++;
|
|
620
|
+
this.#socketLive = true;
|
|
621
|
+
this.#adopt(message.flags.map(fromWire).filter(isPresent));
|
|
622
|
+
}
|
|
623
|
+
#teardown() {
|
|
624
|
+
const socket = this.#socket;
|
|
625
|
+
this.#socket = void 0;
|
|
626
|
+
this.#socketLive = false;
|
|
627
|
+
clearInterval(this.#pingTimer);
|
|
628
|
+
clearTimeout(this.#pongTimer);
|
|
629
|
+
clearTimeout(this.#reconnectTimer);
|
|
630
|
+
this.#pingTimer = this.#pongTimer = this.#reconnectTimer = void 0;
|
|
631
|
+
try {
|
|
632
|
+
socket?.close(NORMAL_CLOSURE);
|
|
633
|
+
} catch {}
|
|
634
|
+
this.#update({ streaming: false });
|
|
635
|
+
}
|
|
636
|
+
#unpark() {
|
|
637
|
+
this.#parked = void 0;
|
|
638
|
+
this.#connect();
|
|
639
|
+
}
|
|
640
|
+
#disconnected(code, opened) {
|
|
641
|
+
this.#teardown();
|
|
642
|
+
let delay;
|
|
643
|
+
if (!opened && ++this.#failedConnects >= MAX_FAILED_CONNECTS) this.#parked = "connect";
|
|
644
|
+
else if (code === 1008) this.#streamStopped = true;
|
|
645
|
+
else if (code === 4429) delay = RATE_LIMITED_RECONNECT_MS;
|
|
646
|
+
else if (code === 1007 || code === 1009) this.#parked = "context";
|
|
647
|
+
else delay = backoff(this.#reconnectAttempt++, STREAM_RETRY_MAX_MS);
|
|
648
|
+
if (delay !== void 0) this.#reconnectTimer = setTimeout(() => this.#connect(), delay);
|
|
649
|
+
}
|
|
650
|
+
/** One OFREP bulk request, mapped to an {@link Outcome}. Never rejects. */
|
|
651
|
+
async #fetchAll(context, signal) {
|
|
652
|
+
const wait = this.#blockedUntil - Date.now();
|
|
653
|
+
if (wait > 0) {
|
|
654
|
+
if (!await new Promise((resolve) => {
|
|
655
|
+
const timer = setTimeout(() => resolve(true), wait);
|
|
656
|
+
signal.addEventListener("abort", () => {
|
|
657
|
+
clearTimeout(timer);
|
|
658
|
+
resolve(false);
|
|
659
|
+
}, { once: true });
|
|
660
|
+
})) return { kind: "aborted" };
|
|
661
|
+
}
|
|
662
|
+
const timeout = new AbortController();
|
|
663
|
+
const timer = setTimeout(() => timeout.abort(), this.#timeoutMs);
|
|
664
|
+
const onAbort = () => timeout.abort();
|
|
665
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
666
|
+
try {
|
|
667
|
+
let response;
|
|
668
|
+
try {
|
|
669
|
+
response = await this.#fetch(this.#url, {
|
|
670
|
+
method: "POST",
|
|
671
|
+
headers: {
|
|
672
|
+
Authorization: `Bearer ${this.#token}`,
|
|
673
|
+
"Content-Type": "application/json"
|
|
674
|
+
},
|
|
675
|
+
body: JSON.stringify({ context }),
|
|
676
|
+
signal: timeout.signal
|
|
677
|
+
});
|
|
678
|
+
} catch (e) {
|
|
679
|
+
if (signal.aborted) return { kind: "aborted" };
|
|
680
|
+
if (timeout.signal.aborted) return {
|
|
681
|
+
kind: "retry",
|
|
682
|
+
message: `The request timed out after ${this.#timeoutMs} ms`
|
|
683
|
+
};
|
|
684
|
+
return {
|
|
685
|
+
kind: "retry",
|
|
686
|
+
message: `The request failed: ${describe(e)}`
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
return await this.#readResponse(response);
|
|
690
|
+
} catch (e) {
|
|
691
|
+
if (signal.aborted) return { kind: "aborted" };
|
|
692
|
+
return {
|
|
693
|
+
kind: "retry",
|
|
694
|
+
message: `The response could not be read: ${describe(e)}`
|
|
695
|
+
};
|
|
696
|
+
} finally {
|
|
697
|
+
clearTimeout(timer);
|
|
698
|
+
signal.removeEventListener("abort", onAbort);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async #readResponse(response) {
|
|
702
|
+
switch (response.status) {
|
|
703
|
+
case 401: return {
|
|
704
|
+
kind: "fatal",
|
|
705
|
+
message: "The API rejected the public token (HTTP 401). Check that `publicToken` is the environment's ffp_… token."
|
|
706
|
+
};
|
|
707
|
+
case 403: return {
|
|
708
|
+
kind: "fatal",
|
|
709
|
+
message: "The token cannot evaluate flags (HTTP 403)."
|
|
710
|
+
};
|
|
711
|
+
case 429: {
|
|
712
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
713
|
+
return {
|
|
714
|
+
kind: "retry",
|
|
715
|
+
message: `The API rate limit was reached (HTTP 429)${retryAfterMs !== void 0 ? `. Retrying in ${Math.ceil(retryAfterMs / 1e3)} s` : ""}.`,
|
|
716
|
+
retryAfterMs
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
case 400: {
|
|
720
|
+
const body = await response.json().catch(() => void 0);
|
|
721
|
+
return {
|
|
722
|
+
kind: "rejected",
|
|
723
|
+
message: `The API rejected the evaluation context (${isRecord(body) && typeof body.errorCode === "string" ? body.errorCode : "GENERAL"})${isRecord(body) && typeof body.errorDetails === "string" ? `: ${body.errorDetails}` : ""}`
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (!response.ok) return {
|
|
728
|
+
kind: "retry",
|
|
729
|
+
message: `Unexpected response status ${response.status}`
|
|
730
|
+
};
|
|
731
|
+
const body = await response.json().catch(() => void 0);
|
|
732
|
+
if (!isRecord(body) || !Array.isArray(body.flags)) return {
|
|
733
|
+
kind: "retry",
|
|
734
|
+
message: "The response is not a valid OFREP bulk evaluation response (PARSE_ERROR)"
|
|
735
|
+
};
|
|
736
|
+
return {
|
|
737
|
+
kind: "ok",
|
|
738
|
+
flags: body.flags.map(fromWire).filter(isPresent)
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
export { ToggldClient as t };
|