@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 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
- dataPlaneUrl: "https://data.feat.so",
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, dataPlaneUrl });
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
- - Polls every 30 s by default (configurable down to 5 s). ETag-aware: unchanged polls are 304s.
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
- - `dataPlaneUrl` must use `https://` (the constructor rejects plaintext URLs except `http://localhost` for tests).
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.1.1";
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
- assertHttpsUrl(config.dataPlaneUrl);
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
- timer = null;
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
- if (this.timer) {
57
- clearInterval(this.timer);
58
- this.timer = null;
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.timer = setInterval(() => {
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
- console.warn(
66
- "feat: background poll failed:",
67
- err instanceof Error ? err.message : String(err)
68
- );
69
- });
70
- }, this.pollIntervalMs);
71
- const t = this.timer;
72
- t.unref?.();
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.config.dataPlaneUrl.replace(/\/$/, "")}/sdk/v1/datafile`;
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
- this.datafile = next;
93
- this.etag = res.headers.get("etag");
94
- return true;
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("dataPlaneUrl must use https:// (http://localhost allowed for tests)");
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
- dataPlaneUrl: string;
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 timer;
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
- dataPlaneUrl: string;
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 timer;
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.1.1";
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
- assertHttpsUrl(config.dataPlaneUrl);
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
- timer = null;
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
- if (this.timer) {
56
- clearInterval(this.timer);
57
- this.timer = null;
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.timer = setInterval(() => {
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
- console.warn(
65
- "feat: background poll failed:",
66
- err instanceof Error ? err.message : String(err)
67
- );
68
- });
69
- }, this.pollIntervalMs);
70
- const t = this.timer;
71
- t.unref?.();
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.config.dataPlaneUrl.replace(/\/$/, "")}/sdk/v1/datafile`;
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
- this.datafile = next;
92
- this.etag = res.headers.get("etag");
93
- return true;
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("dataPlaneUrl must use https:// (http://localhost allowed for tests)");
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.1.1",
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": {