@feathq/js-sdk 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/dist/index.cjs +304 -20
- package/dist/index.d.cts +34 -3
- package/dist/index.d.ts +34 -3
- package/dist/index.js +304 -21
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ import { FeatClient } from "@feathq/js-sdk";
|
|
|
29
29
|
|
|
30
30
|
const client = new FeatClient({
|
|
31
31
|
apiKey: process.env.FEAT_SERVER_KEY!, // feat_sdk_…
|
|
32
|
-
|
|
32
|
+
url: "https://data-01.feat.so", // optional; this is the default
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
await client.ready();
|
|
@@ -52,7 +52,7 @@ Use a **server** API key (`feat_sdk_…`). Mobile and client-side keys are for t
|
|
|
52
52
|
import { OpenFeature } from "@openfeature/server-sdk";
|
|
53
53
|
import { FeatClient, FeatProvider } from "@feathq/js-sdk";
|
|
54
54
|
|
|
55
|
-
const featClient = new FeatClient({ apiKey
|
|
55
|
+
const featClient = new FeatClient({ apiKey });
|
|
56
56
|
await OpenFeature.setProviderAndWait(new FeatProvider(featClient));
|
|
57
57
|
|
|
58
58
|
const client = OpenFeature.getClient();
|
|
@@ -64,9 +64,12 @@ const enabled = await client.getBooleanValue("checkout-v2", false, {
|
|
|
64
64
|
## How it works
|
|
65
65
|
|
|
66
66
|
- The SDK fetches a per-environment **datafile** and keeps it in memory.
|
|
67
|
-
-
|
|
67
|
+
- **Live streaming is on by default.** After the initial fetch the SDK opens a Server-Sent Events stream and applies each pushed datafile the moment it changes. Updates are applied in version order: a push is adopted only when its `version` is strictly newer than the one in memory.
|
|
68
|
+
- A background poll keeps running as a safety net (slow while the stream is healthy). If the stream cannot establish or drops, the SDK falls back to polling at the normal interval and keeps retrying the stream with backoff.
|
|
69
|
+
- Set `streaming: false` to rely on polling alone. Poll cadence is `pollIntervalMs` (default 30 s, floored at 5 s). ETag-aware: unchanged polls are 304s.
|
|
68
70
|
- Evaluation is local; no per-flag network call.
|
|
69
|
-
- `
|
|
71
|
+
- Call `client.close()` to tear down the stream and poll loop.
|
|
72
|
+
- `url` must use `https://` if you override it (the constructor rejects plaintext URLs except `http://localhost` for tests).
|
|
70
73
|
|
|
71
74
|
## License
|
|
72
75
|
|
package/dist/index.cjs
CHANGED
|
@@ -5,31 +5,189 @@ var core = require('@openfeature/core');
|
|
|
5
5
|
|
|
6
6
|
// src/client.ts
|
|
7
7
|
|
|
8
|
+
// src/streaming.ts
|
|
9
|
+
var STREAM_MAX_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
var SseHttpError = class extends Error {
|
|
11
|
+
constructor(status) {
|
|
12
|
+
super(`datafile stream failed: ${status}`);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.name = "SseHttpError";
|
|
15
|
+
}
|
|
16
|
+
status;
|
|
17
|
+
};
|
|
18
|
+
var fetchSseTransport = async (options) => {
|
|
19
|
+
const { url, headers, fetch: fetchImpl, signal, onOpen, onFrame, maxBytes } = options;
|
|
20
|
+
const res = await fetchImpl(url, {
|
|
21
|
+
method: "GET",
|
|
22
|
+
headers: { Accept: "text/event-stream", ...headers },
|
|
23
|
+
signal
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new SseHttpError(res.status);
|
|
27
|
+
}
|
|
28
|
+
if (!res.body) {
|
|
29
|
+
throw new Error("datafile stream returned no body");
|
|
30
|
+
}
|
|
31
|
+
onOpen?.();
|
|
32
|
+
const reader = res.body.getReader();
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
const parser = new SseParser(onFrame, maxBytes ?? STREAM_MAX_BYTES);
|
|
35
|
+
try {
|
|
36
|
+
for (; ; ) {
|
|
37
|
+
const { done, value } = await reader.read();
|
|
38
|
+
if (done) break;
|
|
39
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
40
|
+
}
|
|
41
|
+
parser.push(decoder.decode());
|
|
42
|
+
parser.flush();
|
|
43
|
+
} finally {
|
|
44
|
+
reader.releaseLock();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var SseParser = class {
|
|
48
|
+
constructor(onFrame, maxBytes = STREAM_MAX_BYTES) {
|
|
49
|
+
this.onFrame = onFrame;
|
|
50
|
+
this.maxBytes = maxBytes;
|
|
51
|
+
}
|
|
52
|
+
onFrame;
|
|
53
|
+
maxBytes;
|
|
54
|
+
buffer = "";
|
|
55
|
+
eventType = "";
|
|
56
|
+
dataLines = [];
|
|
57
|
+
dataBytes = 0;
|
|
58
|
+
lastId = null;
|
|
59
|
+
push(chunk) {
|
|
60
|
+
this.buffer += chunk;
|
|
61
|
+
if (this.buffer.length > this.maxBytes) {
|
|
62
|
+
throw new Error(`datafile stream line exceeds ${this.maxBytes} bytes`);
|
|
63
|
+
}
|
|
64
|
+
let newlineIndex;
|
|
65
|
+
while ((newlineIndex = this.indexOfLineBreak(this.buffer)) !== -1) {
|
|
66
|
+
if (this.buffer[newlineIndex] === "\r" && newlineIndex === this.buffer.length - 1) {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
const line = this.buffer.slice(0, newlineIndex);
|
|
70
|
+
this.buffer = this.buffer.slice(newlineIndex + this.lineBreakLength(newlineIndex));
|
|
71
|
+
this.handleLine(line);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Leftover bytes are passed to handleLine but dispatch() is never called, so
|
|
75
|
+
// a frame missing its terminating blank line is discarded. That is
|
|
76
|
+
// spec-correct: an unterminated final frame is not delivered.
|
|
77
|
+
flush() {
|
|
78
|
+
if (this.buffer.endsWith("\r")) this.buffer = this.buffer.slice(0, -1);
|
|
79
|
+
if (this.buffer.length > 0) {
|
|
80
|
+
this.handleLine(this.buffer);
|
|
81
|
+
this.buffer = "";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
indexOfLineBreak(s) {
|
|
85
|
+
const lf = s.indexOf("\n");
|
|
86
|
+
const cr = s.indexOf("\r");
|
|
87
|
+
if (lf === -1) return cr;
|
|
88
|
+
if (cr === -1) return lf;
|
|
89
|
+
return Math.min(lf, cr);
|
|
90
|
+
}
|
|
91
|
+
lineBreakLength(index) {
|
|
92
|
+
if (this.buffer[index] === "\r" && this.buffer[index + 1] === "\n") return 2;
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
handleLine(line) {
|
|
96
|
+
if (line === "") {
|
|
97
|
+
this.dispatch();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (line.startsWith(":")) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const colon = line.indexOf(":");
|
|
104
|
+
let field;
|
|
105
|
+
let value;
|
|
106
|
+
if (colon === -1) {
|
|
107
|
+
field = line;
|
|
108
|
+
value = "";
|
|
109
|
+
} else {
|
|
110
|
+
field = line.slice(0, colon);
|
|
111
|
+
value = line.slice(colon + 1);
|
|
112
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
113
|
+
}
|
|
114
|
+
switch (field) {
|
|
115
|
+
case "event":
|
|
116
|
+
this.eventType = value;
|
|
117
|
+
break;
|
|
118
|
+
case "data":
|
|
119
|
+
this.dataBytes += value.length;
|
|
120
|
+
if (this.dataBytes > this.maxBytes) {
|
|
121
|
+
throw new Error(`datafile stream frame exceeds ${this.maxBytes} bytes`);
|
|
122
|
+
}
|
|
123
|
+
this.dataLines.push(value);
|
|
124
|
+
break;
|
|
125
|
+
case "id":
|
|
126
|
+
if (!value.includes("\0")) this.lastId = value;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
dispatch() {
|
|
131
|
+
if (this.dataLines.length === 0) {
|
|
132
|
+
this.eventType = "";
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const frame = {
|
|
136
|
+
event: this.eventType === "" ? "message" : this.eventType,
|
|
137
|
+
id: this.lastId,
|
|
138
|
+
data: this.dataLines.join("\n")
|
|
139
|
+
};
|
|
140
|
+
this.eventType = "";
|
|
141
|
+
this.dataLines = [];
|
|
142
|
+
this.dataBytes = 0;
|
|
143
|
+
this.onFrame(frame);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
8
147
|
// src/version.ts
|
|
9
|
-
var SDK_VERSION = "0.
|
|
148
|
+
var SDK_VERSION = "0.2.0";
|
|
10
149
|
|
|
11
150
|
// src/client.ts
|
|
12
151
|
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
13
152
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
153
|
+
var DEFAULT_SAFETY_NET_POLL_INTERVAL_MS = 15 * 60 * 1e3;
|
|
154
|
+
var STREAM_BACKOFF_INITIAL_MS = 1e3;
|
|
155
|
+
var STREAM_BACKOFF_MAX_MS = 3e4;
|
|
156
|
+
var STREAM_HEALTHY_RESET_MS = 5e3;
|
|
14
157
|
var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
|
|
158
|
+
var DEFAULT_URL = "https://data-01.feat.so";
|
|
15
159
|
var USER_AGENT = `feat-sdk-js/${SDK_VERSION}`;
|
|
16
160
|
var FeatClient = class {
|
|
17
161
|
constructor(config) {
|
|
18
162
|
this.config = config;
|
|
19
|
-
|
|
163
|
+
this.url = config.url ?? DEFAULT_URL;
|
|
164
|
+
assertHttpsUrl(this.url);
|
|
20
165
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
21
166
|
this.pollIntervalMs = Math.max(
|
|
22
167
|
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
23
168
|
MIN_POLL_INTERVAL_MS
|
|
24
169
|
);
|
|
170
|
+
this.safetyNetPollIntervalMs = Math.max(
|
|
171
|
+
DEFAULT_SAFETY_NET_POLL_INTERVAL_MS,
|
|
172
|
+
this.pollIntervalMs
|
|
173
|
+
);
|
|
174
|
+
this.streamingEnabled = config.streaming ?? true;
|
|
175
|
+
this.streamTransport = config.streamTransport ?? fetchSseTransport;
|
|
25
176
|
}
|
|
26
177
|
config;
|
|
27
178
|
datafile = null;
|
|
28
179
|
etag = null;
|
|
29
|
-
|
|
180
|
+
pollTimer = null;
|
|
30
181
|
readyPromise = null;
|
|
182
|
+
closed = false;
|
|
183
|
+
streamAbort = null;
|
|
184
|
+
streamConnected = false;
|
|
31
185
|
fetchImpl;
|
|
32
186
|
pollIntervalMs;
|
|
187
|
+
safetyNetPollIntervalMs;
|
|
188
|
+
url;
|
|
189
|
+
streamingEnabled;
|
|
190
|
+
streamTransport;
|
|
33
191
|
async ready() {
|
|
34
192
|
if (!this.readyPromise) {
|
|
35
193
|
this.readyPromise = this.bootstrap();
|
|
@@ -53,26 +211,111 @@ var FeatClient = class {
|
|
|
53
211
|
return result;
|
|
54
212
|
}
|
|
55
213
|
close() {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.
|
|
214
|
+
this.closed = true;
|
|
215
|
+
if (this.pollTimer) {
|
|
216
|
+
clearTimeout(this.pollTimer);
|
|
217
|
+
this.pollTimer = null;
|
|
218
|
+
}
|
|
219
|
+
if (this.streamAbort) {
|
|
220
|
+
this.streamAbort.abort();
|
|
221
|
+
this.streamAbort = null;
|
|
59
222
|
}
|
|
60
223
|
}
|
|
61
224
|
async bootstrap() {
|
|
62
225
|
await this.fetchDatafile();
|
|
63
|
-
this.
|
|
226
|
+
if (this.closed) return;
|
|
227
|
+
if (this.streamingEnabled) {
|
|
228
|
+
void this.runStreamLoop();
|
|
229
|
+
}
|
|
230
|
+
this.scheduleNextPoll();
|
|
231
|
+
}
|
|
232
|
+
// Self-scheduling poll. The interval depends on stream health: slow while
|
|
233
|
+
// streaming is healthy, normal otherwise (the fallback path).
|
|
234
|
+
scheduleNextPoll() {
|
|
235
|
+
if (this.closed) return;
|
|
236
|
+
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
237
|
+
const interval = this.streamingEnabled && this.streamConnected ? this.safetyNetPollIntervalMs : this.pollIntervalMs;
|
|
238
|
+
this.pollTimer = setTimeout(() => {
|
|
64
239
|
void this.fetchDatafile().catch((err) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
240
|
+
warn("background poll failed:", err);
|
|
241
|
+
}).finally(() => this.scheduleNextPoll());
|
|
242
|
+
}, interval);
|
|
243
|
+
unref(this.pollTimer);
|
|
244
|
+
}
|
|
245
|
+
async runStreamLoop() {
|
|
246
|
+
const streamUrl = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile/stream`;
|
|
247
|
+
const headers = {
|
|
248
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
249
|
+
"User-Agent": USER_AGENT
|
|
250
|
+
};
|
|
251
|
+
let backoff = STREAM_BACKOFF_INITIAL_MS;
|
|
252
|
+
while (!this.closed) {
|
|
253
|
+
const abort = new AbortController();
|
|
254
|
+
this.streamAbort = abort;
|
|
255
|
+
let connectedAt = null;
|
|
256
|
+
try {
|
|
257
|
+
await this.streamTransport({
|
|
258
|
+
url: streamUrl,
|
|
259
|
+
headers,
|
|
260
|
+
fetch: this.fetchImpl,
|
|
261
|
+
signal: abort.signal,
|
|
262
|
+
onOpen: () => {
|
|
263
|
+
connectedAt = Date.now();
|
|
264
|
+
this.setStreamConnected(true);
|
|
265
|
+
},
|
|
266
|
+
onFrame: (frame) => this.handleFrame(frame)
|
|
267
|
+
});
|
|
268
|
+
this.setStreamConnected(false);
|
|
269
|
+
if (connectedAt !== null && Date.now() - connectedAt >= STREAM_HEALTHY_RESET_MS) {
|
|
270
|
+
backoff = STREAM_BACKOFF_INITIAL_MS;
|
|
271
|
+
} else {
|
|
272
|
+
backoff = Math.min(backoff * 2, STREAM_BACKOFF_MAX_MS);
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
this.setStreamConnected(false);
|
|
276
|
+
if (this.closed || isAbortError(err)) break;
|
|
277
|
+
if (isTerminalStreamStatus(err)) {
|
|
278
|
+
warn("datafile stream rejected (auth); falling back to polling:", err);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
warn("datafile stream error:", err);
|
|
282
|
+
backoff = Math.min(backoff * 2, STREAM_BACKOFF_MAX_MS);
|
|
283
|
+
}
|
|
284
|
+
if (this.closed) break;
|
|
285
|
+
await abortableDelay(jitter(backoff), abort.signal);
|
|
286
|
+
}
|
|
287
|
+
this.setStreamConnected(false);
|
|
288
|
+
}
|
|
289
|
+
setStreamConnected(connected) {
|
|
290
|
+
if (this.streamConnected === connected) return;
|
|
291
|
+
this.streamConnected = connected;
|
|
292
|
+
this.scheduleNextPoll();
|
|
293
|
+
}
|
|
294
|
+
handleFrame(frame) {
|
|
295
|
+
if (frame.event !== "put") return;
|
|
296
|
+
let next;
|
|
297
|
+
try {
|
|
298
|
+
next = JSON.parse(frame.data);
|
|
299
|
+
} catch {
|
|
300
|
+
warn("ignoring stream frame with invalid datafile JSON");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.adoptDatafile(next);
|
|
304
|
+
}
|
|
305
|
+
// Adopt a datafile only if its version is strictly newer than what we
|
|
306
|
+
// hold. Equal or older versions are ignored so out-of-order pushes or a
|
|
307
|
+
// stale poll can never roll the datafile backwards. Returns true if
|
|
308
|
+
// adopted.
|
|
309
|
+
adoptDatafile(next) {
|
|
310
|
+
if (typeof next?.version !== "number") return false;
|
|
311
|
+
const current = this.datafile?.version ?? Number.NEGATIVE_INFINITY;
|
|
312
|
+
if (next.version <= current) return false;
|
|
313
|
+
this.datafile = next;
|
|
314
|
+
if (typeof next.etag === "string") this.etag = next.etag;
|
|
315
|
+
return true;
|
|
73
316
|
}
|
|
74
317
|
async fetchDatafile() {
|
|
75
|
-
const url = `${this.
|
|
318
|
+
const url = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile`;
|
|
76
319
|
const headers = {
|
|
77
320
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
78
321
|
"User-Agent": USER_AGENT
|
|
@@ -89,11 +332,51 @@ var FeatClient = class {
|
|
|
89
332
|
throw new Error("datafile exceeds maximum allowed size");
|
|
90
333
|
}
|
|
91
334
|
const next = await res.json();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
335
|
+
const adopted = this.adoptDatafile(next);
|
|
336
|
+
if (adopted) {
|
|
337
|
+
const headerEtag = res.headers.get("etag");
|
|
338
|
+
if (headerEtag) this.etag = headerEtag;
|
|
339
|
+
}
|
|
340
|
+
return adopted;
|
|
95
341
|
}
|
|
96
342
|
};
|
|
343
|
+
function warn(message, err) {
|
|
344
|
+
if (err === void 0) {
|
|
345
|
+
console.warn(`feat: ${message}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
console.warn(`feat: ${message}`, err instanceof Error ? err.message : String(err));
|
|
349
|
+
}
|
|
350
|
+
function isAbortError(err) {
|
|
351
|
+
return err instanceof Error && err.name === "AbortError";
|
|
352
|
+
}
|
|
353
|
+
function isTerminalStreamStatus(err) {
|
|
354
|
+
return err instanceof SseHttpError && (err.status === 401 || err.status === 403);
|
|
355
|
+
}
|
|
356
|
+
function jitter(ms) {
|
|
357
|
+
return ms * (0.5 + Math.random() * 0.5);
|
|
358
|
+
}
|
|
359
|
+
function unref(timer) {
|
|
360
|
+
timer.unref?.();
|
|
361
|
+
}
|
|
362
|
+
function abortableDelay(ms, signal) {
|
|
363
|
+
return new Promise((resolve) => {
|
|
364
|
+
if (signal.aborted) {
|
|
365
|
+
resolve();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const timer = setTimeout(() => {
|
|
369
|
+
signal.removeEventListener("abort", onAbort);
|
|
370
|
+
resolve();
|
|
371
|
+
}, ms);
|
|
372
|
+
unref(timer);
|
|
373
|
+
function onAbort() {
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
resolve();
|
|
376
|
+
}
|
|
377
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
378
|
+
});
|
|
379
|
+
}
|
|
97
380
|
function assertHttpsUrl(url) {
|
|
98
381
|
try {
|
|
99
382
|
const u = new URL(url);
|
|
@@ -103,7 +386,7 @@ function assertHttpsUrl(url) {
|
|
|
103
386
|
}
|
|
104
387
|
} catch {
|
|
105
388
|
}
|
|
106
|
-
throw new Error("
|
|
389
|
+
throw new Error("url must use https:// (http://localhost allowed for tests)");
|
|
107
390
|
}
|
|
108
391
|
var FeatProvider = class {
|
|
109
392
|
constructor(client) {
|
|
@@ -200,3 +483,4 @@ Object.defineProperty(exports, "evaluate", {
|
|
|
200
483
|
});
|
|
201
484
|
exports.FeatClient = FeatClient;
|
|
202
485
|
exports.FeatProvider = FeatProvider;
|
|
486
|
+
exports.fetchSseTransport = fetchSseTransport;
|
package/dist/index.d.cts
CHANGED
|
@@ -4,26 +4,57 @@ import { ProviderMetadata, EvaluationContext, Logger, ResolutionDetails, JsonVal
|
|
|
4
4
|
import { Provider, Hook, ProviderStatus } from '@openfeature/server-sdk';
|
|
5
5
|
export { Datafile, Operator } from '@feathq/datafile-schema';
|
|
6
6
|
|
|
7
|
+
interface SseFrame {
|
|
8
|
+
event: string;
|
|
9
|
+
id: string | null;
|
|
10
|
+
data: string;
|
|
11
|
+
}
|
|
12
|
+
interface SseTransportOptions {
|
|
13
|
+
url: string;
|
|
14
|
+
headers: Record<string, string>;
|
|
15
|
+
fetch: typeof fetch;
|
|
16
|
+
signal: AbortSignal;
|
|
17
|
+
maxBytes?: number;
|
|
18
|
+
onOpen?: () => void;
|
|
19
|
+
onFrame: (frame: SseFrame) => void;
|
|
20
|
+
}
|
|
21
|
+
type SseTransport = (options: SseTransportOptions) => Promise<void>;
|
|
22
|
+
declare const fetchSseTransport: SseTransport;
|
|
23
|
+
|
|
7
24
|
interface FeatClientConfig {
|
|
8
25
|
apiKey: string;
|
|
9
|
-
|
|
26
|
+
url?: string;
|
|
10
27
|
pollIntervalMs?: number;
|
|
28
|
+
streaming?: boolean;
|
|
29
|
+
streamTransport?: SseTransport;
|
|
11
30
|
fetch?: typeof fetch;
|
|
12
31
|
}
|
|
13
32
|
declare class FeatClient {
|
|
14
33
|
private readonly config;
|
|
15
34
|
private datafile;
|
|
16
35
|
private etag;
|
|
17
|
-
private
|
|
36
|
+
private pollTimer;
|
|
18
37
|
private readyPromise;
|
|
38
|
+
private closed;
|
|
39
|
+
private streamAbort;
|
|
40
|
+
private streamConnected;
|
|
19
41
|
private readonly fetchImpl;
|
|
20
42
|
private readonly pollIntervalMs;
|
|
43
|
+
private readonly safetyNetPollIntervalMs;
|
|
44
|
+
private readonly url;
|
|
45
|
+
private readonly streamingEnabled;
|
|
46
|
+
private readonly streamTransport;
|
|
21
47
|
constructor(config: FeatClientConfig);
|
|
22
48
|
ready(): Promise<void>;
|
|
23
49
|
refresh(): Promise<boolean>;
|
|
24
50
|
evaluate<T = unknown>(flagKey: string, defaultValue: T, context: EvalContext): Promise<EvaluationResult<T>>;
|
|
25
51
|
close(): void;
|
|
26
52
|
private bootstrap;
|
|
53
|
+
private scheduleNextPoll;
|
|
54
|
+
private runStreamLoop;
|
|
55
|
+
private setStreamConnected;
|
|
56
|
+
private handleFrame;
|
|
57
|
+
private adoptDatafile;
|
|
27
58
|
private fetchDatafile;
|
|
28
59
|
}
|
|
29
60
|
|
|
@@ -42,4 +73,4 @@ declare class FeatProvider implements Provider {
|
|
|
42
73
|
resolveObjectEvaluation<T extends JsonValue>(flagKey: string, defaultValue: T, context: EvaluationContext, _logger: Logger): Promise<ResolutionDetails<T>>;
|
|
43
74
|
}
|
|
44
75
|
|
|
45
|
-
export { FeatClient, type FeatClientConfig, FeatProvider };
|
|
76
|
+
export { FeatClient, type FeatClientConfig, FeatProvider, type SseFrame, type SseTransport, type SseTransportOptions, fetchSseTransport };
|
package/dist/index.d.ts
CHANGED
|
@@ -4,26 +4,57 @@ import { ProviderMetadata, EvaluationContext, Logger, ResolutionDetails, JsonVal
|
|
|
4
4
|
import { Provider, Hook, ProviderStatus } from '@openfeature/server-sdk';
|
|
5
5
|
export { Datafile, Operator } from '@feathq/datafile-schema';
|
|
6
6
|
|
|
7
|
+
interface SseFrame {
|
|
8
|
+
event: string;
|
|
9
|
+
id: string | null;
|
|
10
|
+
data: string;
|
|
11
|
+
}
|
|
12
|
+
interface SseTransportOptions {
|
|
13
|
+
url: string;
|
|
14
|
+
headers: Record<string, string>;
|
|
15
|
+
fetch: typeof fetch;
|
|
16
|
+
signal: AbortSignal;
|
|
17
|
+
maxBytes?: number;
|
|
18
|
+
onOpen?: () => void;
|
|
19
|
+
onFrame: (frame: SseFrame) => void;
|
|
20
|
+
}
|
|
21
|
+
type SseTransport = (options: SseTransportOptions) => Promise<void>;
|
|
22
|
+
declare const fetchSseTransport: SseTransport;
|
|
23
|
+
|
|
7
24
|
interface FeatClientConfig {
|
|
8
25
|
apiKey: string;
|
|
9
|
-
|
|
26
|
+
url?: string;
|
|
10
27
|
pollIntervalMs?: number;
|
|
28
|
+
streaming?: boolean;
|
|
29
|
+
streamTransport?: SseTransport;
|
|
11
30
|
fetch?: typeof fetch;
|
|
12
31
|
}
|
|
13
32
|
declare class FeatClient {
|
|
14
33
|
private readonly config;
|
|
15
34
|
private datafile;
|
|
16
35
|
private etag;
|
|
17
|
-
private
|
|
36
|
+
private pollTimer;
|
|
18
37
|
private readyPromise;
|
|
38
|
+
private closed;
|
|
39
|
+
private streamAbort;
|
|
40
|
+
private streamConnected;
|
|
19
41
|
private readonly fetchImpl;
|
|
20
42
|
private readonly pollIntervalMs;
|
|
43
|
+
private readonly safetyNetPollIntervalMs;
|
|
44
|
+
private readonly url;
|
|
45
|
+
private readonly streamingEnabled;
|
|
46
|
+
private readonly streamTransport;
|
|
21
47
|
constructor(config: FeatClientConfig);
|
|
22
48
|
ready(): Promise<void>;
|
|
23
49
|
refresh(): Promise<boolean>;
|
|
24
50
|
evaluate<T = unknown>(flagKey: string, defaultValue: T, context: EvalContext): Promise<EvaluationResult<T>>;
|
|
25
51
|
close(): void;
|
|
26
52
|
private bootstrap;
|
|
53
|
+
private scheduleNextPoll;
|
|
54
|
+
private runStreamLoop;
|
|
55
|
+
private setStreamConnected;
|
|
56
|
+
private handleFrame;
|
|
57
|
+
private adoptDatafile;
|
|
27
58
|
private fetchDatafile;
|
|
28
59
|
}
|
|
29
60
|
|
|
@@ -42,4 +73,4 @@ declare class FeatProvider implements Provider {
|
|
|
42
73
|
resolveObjectEvaluation<T extends JsonValue>(flagKey: string, defaultValue: T, context: EvaluationContext, _logger: Logger): Promise<ResolutionDetails<T>>;
|
|
43
74
|
}
|
|
44
75
|
|
|
45
|
-
export { FeatClient, type FeatClientConfig, FeatProvider };
|
|
76
|
+
export { FeatClient, type FeatClientConfig, FeatProvider, type SseFrame, type SseTransport, type SseTransportOptions, fetchSseTransport };
|
package/dist/index.js
CHANGED
|
@@ -4,31 +4,189 @@ import { ErrorCode } from '@openfeature/core';
|
|
|
4
4
|
|
|
5
5
|
// src/client.ts
|
|
6
6
|
|
|
7
|
+
// src/streaming.ts
|
|
8
|
+
var STREAM_MAX_BYTES = 10 * 1024 * 1024;
|
|
9
|
+
var SseHttpError = class extends Error {
|
|
10
|
+
constructor(status) {
|
|
11
|
+
super(`datafile stream failed: ${status}`);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.name = "SseHttpError";
|
|
14
|
+
}
|
|
15
|
+
status;
|
|
16
|
+
};
|
|
17
|
+
var fetchSseTransport = async (options) => {
|
|
18
|
+
const { url, headers, fetch: fetchImpl, signal, onOpen, onFrame, maxBytes } = options;
|
|
19
|
+
const res = await fetchImpl(url, {
|
|
20
|
+
method: "GET",
|
|
21
|
+
headers: { Accept: "text/event-stream", ...headers },
|
|
22
|
+
signal
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new SseHttpError(res.status);
|
|
26
|
+
}
|
|
27
|
+
if (!res.body) {
|
|
28
|
+
throw new Error("datafile stream returned no body");
|
|
29
|
+
}
|
|
30
|
+
onOpen?.();
|
|
31
|
+
const reader = res.body.getReader();
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
const parser = new SseParser(onFrame, maxBytes ?? STREAM_MAX_BYTES);
|
|
34
|
+
try {
|
|
35
|
+
for (; ; ) {
|
|
36
|
+
const { done, value } = await reader.read();
|
|
37
|
+
if (done) break;
|
|
38
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
39
|
+
}
|
|
40
|
+
parser.push(decoder.decode());
|
|
41
|
+
parser.flush();
|
|
42
|
+
} finally {
|
|
43
|
+
reader.releaseLock();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var SseParser = class {
|
|
47
|
+
constructor(onFrame, maxBytes = STREAM_MAX_BYTES) {
|
|
48
|
+
this.onFrame = onFrame;
|
|
49
|
+
this.maxBytes = maxBytes;
|
|
50
|
+
}
|
|
51
|
+
onFrame;
|
|
52
|
+
maxBytes;
|
|
53
|
+
buffer = "";
|
|
54
|
+
eventType = "";
|
|
55
|
+
dataLines = [];
|
|
56
|
+
dataBytes = 0;
|
|
57
|
+
lastId = null;
|
|
58
|
+
push(chunk) {
|
|
59
|
+
this.buffer += chunk;
|
|
60
|
+
if (this.buffer.length > this.maxBytes) {
|
|
61
|
+
throw new Error(`datafile stream line exceeds ${this.maxBytes} bytes`);
|
|
62
|
+
}
|
|
63
|
+
let newlineIndex;
|
|
64
|
+
while ((newlineIndex = this.indexOfLineBreak(this.buffer)) !== -1) {
|
|
65
|
+
if (this.buffer[newlineIndex] === "\r" && newlineIndex === this.buffer.length - 1) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const line = this.buffer.slice(0, newlineIndex);
|
|
69
|
+
this.buffer = this.buffer.slice(newlineIndex + this.lineBreakLength(newlineIndex));
|
|
70
|
+
this.handleLine(line);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Leftover bytes are passed to handleLine but dispatch() is never called, so
|
|
74
|
+
// a frame missing its terminating blank line is discarded. That is
|
|
75
|
+
// spec-correct: an unterminated final frame is not delivered.
|
|
76
|
+
flush() {
|
|
77
|
+
if (this.buffer.endsWith("\r")) this.buffer = this.buffer.slice(0, -1);
|
|
78
|
+
if (this.buffer.length > 0) {
|
|
79
|
+
this.handleLine(this.buffer);
|
|
80
|
+
this.buffer = "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
indexOfLineBreak(s) {
|
|
84
|
+
const lf = s.indexOf("\n");
|
|
85
|
+
const cr = s.indexOf("\r");
|
|
86
|
+
if (lf === -1) return cr;
|
|
87
|
+
if (cr === -1) return lf;
|
|
88
|
+
return Math.min(lf, cr);
|
|
89
|
+
}
|
|
90
|
+
lineBreakLength(index) {
|
|
91
|
+
if (this.buffer[index] === "\r" && this.buffer[index + 1] === "\n") return 2;
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
handleLine(line) {
|
|
95
|
+
if (line === "") {
|
|
96
|
+
this.dispatch();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (line.startsWith(":")) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const colon = line.indexOf(":");
|
|
103
|
+
let field;
|
|
104
|
+
let value;
|
|
105
|
+
if (colon === -1) {
|
|
106
|
+
field = line;
|
|
107
|
+
value = "";
|
|
108
|
+
} else {
|
|
109
|
+
field = line.slice(0, colon);
|
|
110
|
+
value = line.slice(colon + 1);
|
|
111
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
112
|
+
}
|
|
113
|
+
switch (field) {
|
|
114
|
+
case "event":
|
|
115
|
+
this.eventType = value;
|
|
116
|
+
break;
|
|
117
|
+
case "data":
|
|
118
|
+
this.dataBytes += value.length;
|
|
119
|
+
if (this.dataBytes > this.maxBytes) {
|
|
120
|
+
throw new Error(`datafile stream frame exceeds ${this.maxBytes} bytes`);
|
|
121
|
+
}
|
|
122
|
+
this.dataLines.push(value);
|
|
123
|
+
break;
|
|
124
|
+
case "id":
|
|
125
|
+
if (!value.includes("\0")) this.lastId = value;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
dispatch() {
|
|
130
|
+
if (this.dataLines.length === 0) {
|
|
131
|
+
this.eventType = "";
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const frame = {
|
|
135
|
+
event: this.eventType === "" ? "message" : this.eventType,
|
|
136
|
+
id: this.lastId,
|
|
137
|
+
data: this.dataLines.join("\n")
|
|
138
|
+
};
|
|
139
|
+
this.eventType = "";
|
|
140
|
+
this.dataLines = [];
|
|
141
|
+
this.dataBytes = 0;
|
|
142
|
+
this.onFrame(frame);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
7
146
|
// src/version.ts
|
|
8
|
-
var SDK_VERSION = "0.
|
|
147
|
+
var SDK_VERSION = "0.2.0";
|
|
9
148
|
|
|
10
149
|
// src/client.ts
|
|
11
150
|
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
12
151
|
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
152
|
+
var DEFAULT_SAFETY_NET_POLL_INTERVAL_MS = 15 * 60 * 1e3;
|
|
153
|
+
var STREAM_BACKOFF_INITIAL_MS = 1e3;
|
|
154
|
+
var STREAM_BACKOFF_MAX_MS = 3e4;
|
|
155
|
+
var STREAM_HEALTHY_RESET_MS = 5e3;
|
|
13
156
|
var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
|
|
157
|
+
var DEFAULT_URL = "https://data-01.feat.so";
|
|
14
158
|
var USER_AGENT = `feat-sdk-js/${SDK_VERSION}`;
|
|
15
159
|
var FeatClient = class {
|
|
16
160
|
constructor(config) {
|
|
17
161
|
this.config = config;
|
|
18
|
-
|
|
162
|
+
this.url = config.url ?? DEFAULT_URL;
|
|
163
|
+
assertHttpsUrl(this.url);
|
|
19
164
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
20
165
|
this.pollIntervalMs = Math.max(
|
|
21
166
|
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
22
167
|
MIN_POLL_INTERVAL_MS
|
|
23
168
|
);
|
|
169
|
+
this.safetyNetPollIntervalMs = Math.max(
|
|
170
|
+
DEFAULT_SAFETY_NET_POLL_INTERVAL_MS,
|
|
171
|
+
this.pollIntervalMs
|
|
172
|
+
);
|
|
173
|
+
this.streamingEnabled = config.streaming ?? true;
|
|
174
|
+
this.streamTransport = config.streamTransport ?? fetchSseTransport;
|
|
24
175
|
}
|
|
25
176
|
config;
|
|
26
177
|
datafile = null;
|
|
27
178
|
etag = null;
|
|
28
|
-
|
|
179
|
+
pollTimer = null;
|
|
29
180
|
readyPromise = null;
|
|
181
|
+
closed = false;
|
|
182
|
+
streamAbort = null;
|
|
183
|
+
streamConnected = false;
|
|
30
184
|
fetchImpl;
|
|
31
185
|
pollIntervalMs;
|
|
186
|
+
safetyNetPollIntervalMs;
|
|
187
|
+
url;
|
|
188
|
+
streamingEnabled;
|
|
189
|
+
streamTransport;
|
|
32
190
|
async ready() {
|
|
33
191
|
if (!this.readyPromise) {
|
|
34
192
|
this.readyPromise = this.bootstrap();
|
|
@@ -52,26 +210,111 @@ var FeatClient = class {
|
|
|
52
210
|
return result;
|
|
53
211
|
}
|
|
54
212
|
close() {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
this.
|
|
213
|
+
this.closed = true;
|
|
214
|
+
if (this.pollTimer) {
|
|
215
|
+
clearTimeout(this.pollTimer);
|
|
216
|
+
this.pollTimer = null;
|
|
217
|
+
}
|
|
218
|
+
if (this.streamAbort) {
|
|
219
|
+
this.streamAbort.abort();
|
|
220
|
+
this.streamAbort = null;
|
|
58
221
|
}
|
|
59
222
|
}
|
|
60
223
|
async bootstrap() {
|
|
61
224
|
await this.fetchDatafile();
|
|
62
|
-
this.
|
|
225
|
+
if (this.closed) return;
|
|
226
|
+
if (this.streamingEnabled) {
|
|
227
|
+
void this.runStreamLoop();
|
|
228
|
+
}
|
|
229
|
+
this.scheduleNextPoll();
|
|
230
|
+
}
|
|
231
|
+
// Self-scheduling poll. The interval depends on stream health: slow while
|
|
232
|
+
// streaming is healthy, normal otherwise (the fallback path).
|
|
233
|
+
scheduleNextPoll() {
|
|
234
|
+
if (this.closed) return;
|
|
235
|
+
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
236
|
+
const interval = this.streamingEnabled && this.streamConnected ? this.safetyNetPollIntervalMs : this.pollIntervalMs;
|
|
237
|
+
this.pollTimer = setTimeout(() => {
|
|
63
238
|
void this.fetchDatafile().catch((err) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
239
|
+
warn("background poll failed:", err);
|
|
240
|
+
}).finally(() => this.scheduleNextPoll());
|
|
241
|
+
}, interval);
|
|
242
|
+
unref(this.pollTimer);
|
|
243
|
+
}
|
|
244
|
+
async runStreamLoop() {
|
|
245
|
+
const streamUrl = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile/stream`;
|
|
246
|
+
const headers = {
|
|
247
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
248
|
+
"User-Agent": USER_AGENT
|
|
249
|
+
};
|
|
250
|
+
let backoff = STREAM_BACKOFF_INITIAL_MS;
|
|
251
|
+
while (!this.closed) {
|
|
252
|
+
const abort = new AbortController();
|
|
253
|
+
this.streamAbort = abort;
|
|
254
|
+
let connectedAt = null;
|
|
255
|
+
try {
|
|
256
|
+
await this.streamTransport({
|
|
257
|
+
url: streamUrl,
|
|
258
|
+
headers,
|
|
259
|
+
fetch: this.fetchImpl,
|
|
260
|
+
signal: abort.signal,
|
|
261
|
+
onOpen: () => {
|
|
262
|
+
connectedAt = Date.now();
|
|
263
|
+
this.setStreamConnected(true);
|
|
264
|
+
},
|
|
265
|
+
onFrame: (frame) => this.handleFrame(frame)
|
|
266
|
+
});
|
|
267
|
+
this.setStreamConnected(false);
|
|
268
|
+
if (connectedAt !== null && Date.now() - connectedAt >= STREAM_HEALTHY_RESET_MS) {
|
|
269
|
+
backoff = STREAM_BACKOFF_INITIAL_MS;
|
|
270
|
+
} else {
|
|
271
|
+
backoff = Math.min(backoff * 2, STREAM_BACKOFF_MAX_MS);
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
this.setStreamConnected(false);
|
|
275
|
+
if (this.closed || isAbortError(err)) break;
|
|
276
|
+
if (isTerminalStreamStatus(err)) {
|
|
277
|
+
warn("datafile stream rejected (auth); falling back to polling:", err);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
warn("datafile stream error:", err);
|
|
281
|
+
backoff = Math.min(backoff * 2, STREAM_BACKOFF_MAX_MS);
|
|
282
|
+
}
|
|
283
|
+
if (this.closed) break;
|
|
284
|
+
await abortableDelay(jitter(backoff), abort.signal);
|
|
285
|
+
}
|
|
286
|
+
this.setStreamConnected(false);
|
|
287
|
+
}
|
|
288
|
+
setStreamConnected(connected) {
|
|
289
|
+
if (this.streamConnected === connected) return;
|
|
290
|
+
this.streamConnected = connected;
|
|
291
|
+
this.scheduleNextPoll();
|
|
292
|
+
}
|
|
293
|
+
handleFrame(frame) {
|
|
294
|
+
if (frame.event !== "put") return;
|
|
295
|
+
let next;
|
|
296
|
+
try {
|
|
297
|
+
next = JSON.parse(frame.data);
|
|
298
|
+
} catch {
|
|
299
|
+
warn("ignoring stream frame with invalid datafile JSON");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this.adoptDatafile(next);
|
|
303
|
+
}
|
|
304
|
+
// Adopt a datafile only if its version is strictly newer than what we
|
|
305
|
+
// hold. Equal or older versions are ignored so out-of-order pushes or a
|
|
306
|
+
// stale poll can never roll the datafile backwards. Returns true if
|
|
307
|
+
// adopted.
|
|
308
|
+
adoptDatafile(next) {
|
|
309
|
+
if (typeof next?.version !== "number") return false;
|
|
310
|
+
const current = this.datafile?.version ?? Number.NEGATIVE_INFINITY;
|
|
311
|
+
if (next.version <= current) return false;
|
|
312
|
+
this.datafile = next;
|
|
313
|
+
if (typeof next.etag === "string") this.etag = next.etag;
|
|
314
|
+
return true;
|
|
72
315
|
}
|
|
73
316
|
async fetchDatafile() {
|
|
74
|
-
const url = `${this.
|
|
317
|
+
const url = `${this.url.replace(/\/$/, "")}/sdk/v1/datafile`;
|
|
75
318
|
const headers = {
|
|
76
319
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
77
320
|
"User-Agent": USER_AGENT
|
|
@@ -88,11 +331,51 @@ var FeatClient = class {
|
|
|
88
331
|
throw new Error("datafile exceeds maximum allowed size");
|
|
89
332
|
}
|
|
90
333
|
const next = await res.json();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
334
|
+
const adopted = this.adoptDatafile(next);
|
|
335
|
+
if (adopted) {
|
|
336
|
+
const headerEtag = res.headers.get("etag");
|
|
337
|
+
if (headerEtag) this.etag = headerEtag;
|
|
338
|
+
}
|
|
339
|
+
return adopted;
|
|
94
340
|
}
|
|
95
341
|
};
|
|
342
|
+
function warn(message, err) {
|
|
343
|
+
if (err === void 0) {
|
|
344
|
+
console.warn(`feat: ${message}`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
console.warn(`feat: ${message}`, err instanceof Error ? err.message : String(err));
|
|
348
|
+
}
|
|
349
|
+
function isAbortError(err) {
|
|
350
|
+
return err instanceof Error && err.name === "AbortError";
|
|
351
|
+
}
|
|
352
|
+
function isTerminalStreamStatus(err) {
|
|
353
|
+
return err instanceof SseHttpError && (err.status === 401 || err.status === 403);
|
|
354
|
+
}
|
|
355
|
+
function jitter(ms) {
|
|
356
|
+
return ms * (0.5 + Math.random() * 0.5);
|
|
357
|
+
}
|
|
358
|
+
function unref(timer) {
|
|
359
|
+
timer.unref?.();
|
|
360
|
+
}
|
|
361
|
+
function abortableDelay(ms, signal) {
|
|
362
|
+
return new Promise((resolve) => {
|
|
363
|
+
if (signal.aborted) {
|
|
364
|
+
resolve();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const timer = setTimeout(() => {
|
|
368
|
+
signal.removeEventListener("abort", onAbort);
|
|
369
|
+
resolve();
|
|
370
|
+
}, ms);
|
|
371
|
+
unref(timer);
|
|
372
|
+
function onAbort() {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
resolve();
|
|
375
|
+
}
|
|
376
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
377
|
+
});
|
|
378
|
+
}
|
|
96
379
|
function assertHttpsUrl(url) {
|
|
97
380
|
try {
|
|
98
381
|
const u = new URL(url);
|
|
@@ -102,7 +385,7 @@ function assertHttpsUrl(url) {
|
|
|
102
385
|
}
|
|
103
386
|
} catch {
|
|
104
387
|
}
|
|
105
|
-
throw new Error("
|
|
388
|
+
throw new Error("url must use https:// (http://localhost allowed for tests)");
|
|
106
389
|
}
|
|
107
390
|
var FeatProvider = class {
|
|
108
391
|
constructor(client) {
|
|
@@ -193,4 +476,4 @@ function toEvalContext(ctx) {
|
|
|
193
476
|
return out;
|
|
194
477
|
}
|
|
195
478
|
|
|
196
|
-
export { FeatClient, FeatProvider };
|
|
479
|
+
export { FeatClient, FeatProvider, fetchSseTransport };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feathq/js-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "feat feature-flag SDK for JavaScript and TypeScript (server-side, OpenFeature provider)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"feature-flags",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@feathq/feat-eval": "^0.1.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
+
"@openfeature/core": "^1.0.0",
|
|
51
52
|
"@openfeature/server-sdk": "^1.0.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|