@feathq/web-sdk 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 +21 -0
- package/README.md +88 -0
- package/dist/index.cjs +441 -0
- package/dist/index.d.cts +81 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +438 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 feat HQ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @feathq/web-sdk
|
|
2
|
+
|
|
3
|
+
Browser / client-side SDK for [feat](https://feat.so) feature flags. Polls a per-environment datafile to the browser and evaluates flags locally with a synchronous cache.
|
|
4
|
+
|
|
5
|
+
For server code, use [`@feathq/js-sdk`](../js-sdk). For an OpenFeature web Provider, install [`@feathq/openfeature-web`](../openfeature-web) alongside this package.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @feathq/web-sdk
|
|
11
|
+
# or
|
|
12
|
+
yarn add @feathq/web-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { FeatWebClient } from "@feathq/web-sdk";
|
|
19
|
+
|
|
20
|
+
const client = new FeatWebClient({
|
|
21
|
+
apiKey: "feat_cs_…", // client-side ID key
|
|
22
|
+
dataPlaneUrl: "https://data.feat.so",
|
|
23
|
+
anonymous: { storage: "localStorage" }, // optional: auto-mint a stable anonymous user
|
|
24
|
+
cache: { storage: "localStorage" }, // optional: warm cache across page loads
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await client.ready();
|
|
28
|
+
|
|
29
|
+
const enabled = client.getBooleanValue("checkout-v2", false); // sync
|
|
30
|
+
const greeting = client.getStringValue("hero-greeting", "Hi");
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use a **client-side ID** key (`feat_cs_…`). The key is non-secret and safe to ship in your bundle. Add your site's origin to the key's Authorized URLs in the feat console.
|
|
34
|
+
|
|
35
|
+
## Reacting to flag changes
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
client.on("change", ({ flagKey, newValue }) => {
|
|
39
|
+
console.log(`${flagKey} → ${newValue}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await client.setContext({
|
|
43
|
+
targetingKey: "user-123",
|
|
44
|
+
user: { plan: "pro" },
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`change` events fire per flag whose evaluated value flipped, after either a context change or a datafile refresh.
|
|
49
|
+
|
|
50
|
+
## OpenFeature
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { OpenFeature } from "@openfeature/web-sdk";
|
|
54
|
+
import { FeatWebClient } from "@feathq/web-sdk";
|
|
55
|
+
import { FeatWebProvider } from "@feathq/openfeature-web";
|
|
56
|
+
|
|
57
|
+
const featClient = new FeatWebClient({ apiKey, dataPlaneUrl });
|
|
58
|
+
await OpenFeature.setProviderAndWait(new FeatWebProvider(featClient));
|
|
59
|
+
await OpenFeature.setContext({ targetingKey: "user-123" });
|
|
60
|
+
|
|
61
|
+
const enabled = OpenFeature.getClient().getBooleanValue("checkout-v2", false);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## SSR / hydration
|
|
65
|
+
|
|
66
|
+
Fetch the datafile on the server and pass it through to the client to skip the first round trip:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
new FeatWebClient({ apiKey, dataPlaneUrl, bootstrap: serverProvidedDatafile });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
- Pre-evaluates every flag against the current context into a `Map` so `getValue` is synchronous.
|
|
75
|
+
- Polls every 30 s by default; pauses while the tab is hidden and force-refreshes on visibility restore. Floored at 5 s.
|
|
76
|
+
- Cross-tab `BroadcastChannel` sync: when one tab fetches a new datafile, sibling tabs adopt it without their own network call.
|
|
77
|
+
- 304-aware via `ETag` / `If-None-Match`.
|
|
78
|
+
- `dataPlaneUrl` must use `https://` (the constructor rejects plaintext URLs except `http://localhost` for tests).
|
|
79
|
+
|
|
80
|
+
## Security notes
|
|
81
|
+
|
|
82
|
+
- `cache: { storage: "localStorage" }` persists the full datafile (including flag rules and segment definitions) under `feat:datafile`. Use only on browsers where you're comfortable with that footprint; default is off.
|
|
83
|
+
- `anonymous: { storage: "localStorage" }` writes a stable UUID to `feat:anonymousKey`. Use `storage: "memory"` if you don't want it persisted.
|
|
84
|
+
- `BroadcastChannel("feat:datafile")` broadcasts to all same-origin tabs. Any script on the same origin can subscribe; treat the datafile as same-origin-readable.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var featEval = require('@feathq/feat-eval');
|
|
4
|
+
|
|
5
|
+
// src/client.ts
|
|
6
|
+
|
|
7
|
+
// src/anonymous.ts
|
|
8
|
+
var STORAGE_KEY = "feat:anonymousKey";
|
|
9
|
+
function buildAnonymousContext(config) {
|
|
10
|
+
const key = resolveOrMintAnonymousKey(config.storage);
|
|
11
|
+
return {
|
|
12
|
+
targetingKey: key,
|
|
13
|
+
user: { key, anonymous: true }
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
var memoryKey = null;
|
|
17
|
+
function resolveOrMintAnonymousKey(storage) {
|
|
18
|
+
if (storage === "localStorage" && hasLocalStorage()) {
|
|
19
|
+
try {
|
|
20
|
+
const existing = window.localStorage.getItem(STORAGE_KEY);
|
|
21
|
+
if (existing) return existing;
|
|
22
|
+
const fresh = randomKey();
|
|
23
|
+
window.localStorage.setItem(STORAGE_KEY, fresh);
|
|
24
|
+
return fresh;
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (memoryKey) return memoryKey;
|
|
29
|
+
memoryKey = randomKey();
|
|
30
|
+
return memoryKey;
|
|
31
|
+
}
|
|
32
|
+
function hasLocalStorage() {
|
|
33
|
+
try {
|
|
34
|
+
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function randomKey() {
|
|
40
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
41
|
+
return crypto.randomUUID();
|
|
42
|
+
}
|
|
43
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
44
|
+
const buf = new Uint8Array(16);
|
|
45
|
+
crypto.getRandomValues(buf);
|
|
46
|
+
buf[6] = (buf[6] ?? 0) & 15 | 64;
|
|
47
|
+
buf[8] = (buf[8] ?? 0) & 63 | 128;
|
|
48
|
+
return formatUuid(buf);
|
|
49
|
+
}
|
|
50
|
+
let s = "";
|
|
51
|
+
for (let i = 0; i < 32; i++) {
|
|
52
|
+
s += Math.floor(Math.random() * 16).toString(16);
|
|
53
|
+
}
|
|
54
|
+
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
|
|
55
|
+
}
|
|
56
|
+
function formatUuid(bytes) {
|
|
57
|
+
const hex = [];
|
|
58
|
+
for (let i = 0; i < 16; i++) {
|
|
59
|
+
hex.push(((bytes[i] ?? 0) & 255).toString(16).padStart(2, "0"));
|
|
60
|
+
}
|
|
61
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/broadcast.ts
|
|
65
|
+
var CHANNEL_NAME = "feat:datafile";
|
|
66
|
+
var DatafileBroadcast = class {
|
|
67
|
+
constructor(handler) {
|
|
68
|
+
this.channel = null;
|
|
69
|
+
this.handler = null;
|
|
70
|
+
this.listener = null;
|
|
71
|
+
if (typeof BroadcastChannel === "undefined") return;
|
|
72
|
+
this.handler = handler;
|
|
73
|
+
this.channel = new BroadcastChannel(CHANNEL_NAME);
|
|
74
|
+
this.listener = (ev) => {
|
|
75
|
+
const data = ev.data;
|
|
76
|
+
if (!data || data.type !== "datafile-update" || !this.handler) return;
|
|
77
|
+
this.handler(data);
|
|
78
|
+
};
|
|
79
|
+
this.channel.addEventListener("message", this.listener);
|
|
80
|
+
}
|
|
81
|
+
publish(datafile, etag) {
|
|
82
|
+
if (!this.channel) return;
|
|
83
|
+
const msg = { type: "datafile-update", datafile, etag };
|
|
84
|
+
try {
|
|
85
|
+
this.channel.postMessage(msg);
|
|
86
|
+
} catch {
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
close() {
|
|
90
|
+
if (this.channel && this.listener) {
|
|
91
|
+
this.channel.removeEventListener("message", this.listener);
|
|
92
|
+
}
|
|
93
|
+
this.channel?.close();
|
|
94
|
+
this.channel = null;
|
|
95
|
+
this.handler = null;
|
|
96
|
+
this.listener = null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// src/emitter.ts
|
|
101
|
+
var Emitter = class {
|
|
102
|
+
constructor() {
|
|
103
|
+
this.listeners = {};
|
|
104
|
+
}
|
|
105
|
+
on(event, listener) {
|
|
106
|
+
let set = this.listeners[event];
|
|
107
|
+
if (!set) {
|
|
108
|
+
set = /* @__PURE__ */ new Set();
|
|
109
|
+
this.listeners[event] = set;
|
|
110
|
+
}
|
|
111
|
+
set.add(listener);
|
|
112
|
+
return () => this.off(event, listener);
|
|
113
|
+
}
|
|
114
|
+
off(event, listener) {
|
|
115
|
+
this.listeners[event]?.delete(listener);
|
|
116
|
+
}
|
|
117
|
+
emit(event, arg) {
|
|
118
|
+
const set = this.listeners[event];
|
|
119
|
+
if (!set) return;
|
|
120
|
+
for (const listener of [...set]) {
|
|
121
|
+
try {
|
|
122
|
+
listener(arg);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error("feat-web-sdk: emitter listener threw", err);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
removeAll() {
|
|
129
|
+
this.listeners = {};
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// src/persistence.ts
|
|
134
|
+
var STORAGE_KEY2 = "feat:datafile";
|
|
135
|
+
function loadCachedDatafile(config) {
|
|
136
|
+
if (config.storage !== "localStorage" || !hasLocalStorage2()) return null;
|
|
137
|
+
try {
|
|
138
|
+
const raw = window.localStorage.getItem(STORAGE_KEY2);
|
|
139
|
+
if (!raw) return null;
|
|
140
|
+
const parsed = JSON.parse(raw);
|
|
141
|
+
if (!parsed.datafile || typeof parsed.datafile !== "object") return null;
|
|
142
|
+
const df = parsed.datafile;
|
|
143
|
+
if (typeof df.envId !== "string" || typeof df.version !== "number") return null;
|
|
144
|
+
return { datafile: df, etag: parsed.etag ?? null };
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function saveCachedDatafile(config, payload) {
|
|
150
|
+
if (config.storage !== "localStorage" || !hasLocalStorage2()) return;
|
|
151
|
+
try {
|
|
152
|
+
window.localStorage.setItem(STORAGE_KEY2, JSON.stringify(payload));
|
|
153
|
+
} catch {
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function hasLocalStorage2() {
|
|
157
|
+
try {
|
|
158
|
+
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/client.ts
|
|
165
|
+
var CLIENT_SIDE_PREFIX = "feat_cs_";
|
|
166
|
+
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
167
|
+
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
168
|
+
var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
|
|
169
|
+
var FeatWebClient = class {
|
|
170
|
+
constructor(config) {
|
|
171
|
+
this.config = config;
|
|
172
|
+
this.datafile = null;
|
|
173
|
+
this.etag = null;
|
|
174
|
+
this.context = null;
|
|
175
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
176
|
+
this.timer = null;
|
|
177
|
+
this.readyPromise = null;
|
|
178
|
+
this.visibilityHandler = null;
|
|
179
|
+
this.emitter = new Emitter();
|
|
180
|
+
this.broadcast = null;
|
|
181
|
+
this.closed = false;
|
|
182
|
+
if (!config.apiKey.startsWith(CLIENT_SIDE_PREFIX)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`FeatWebClient requires a client_side_id key (prefix "${CLIENT_SIDE_PREFIX}"). Server and mobile keys must never ship in browser code.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
assertHttpsUrl(config.dataPlaneUrl);
|
|
188
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
189
|
+
this.pollIntervalMs = Math.max(
|
|
190
|
+
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
191
|
+
MIN_POLL_INTERVAL_MS
|
|
192
|
+
);
|
|
193
|
+
if (config.context) {
|
|
194
|
+
this.context = config.context;
|
|
195
|
+
} else if (config.anonymous) {
|
|
196
|
+
this.context = buildAnonymousContext(config.anonymous);
|
|
197
|
+
}
|
|
198
|
+
if (config.bootstrap) {
|
|
199
|
+
this.datafile = config.bootstrap;
|
|
200
|
+
this.etag = config.bootstrap.etag;
|
|
201
|
+
} else if (config.cache) {
|
|
202
|
+
const cached = loadCachedDatafile(config.cache);
|
|
203
|
+
if (cached) {
|
|
204
|
+
this.datafile = cached.datafile;
|
|
205
|
+
this.etag = cached.etag;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (config.crossTabSync !== false) {
|
|
209
|
+
this.broadcast = new DatafileBroadcast((msg) => {
|
|
210
|
+
void this.adoptFromBroadcast(msg.datafile, msg.etag);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Resolves once the first datafile is in memory AND (if context was
|
|
215
|
+
// supplied) the cache has been pre-evaluated.
|
|
216
|
+
async ready() {
|
|
217
|
+
if (!this.readyPromise) {
|
|
218
|
+
this.readyPromise = this.bootstrap();
|
|
219
|
+
}
|
|
220
|
+
return this.readyPromise;
|
|
221
|
+
}
|
|
222
|
+
// Swap the evaluation context. Re-evaluates every flag, diffs against
|
|
223
|
+
// the previous cache, and fires a `change` event per flipped flag.
|
|
224
|
+
// OpenFeature's `onContextChange` lifecycle hook bridges to this.
|
|
225
|
+
async setContext(context) {
|
|
226
|
+
this.context = context;
|
|
227
|
+
await this.recomputeCache();
|
|
228
|
+
}
|
|
229
|
+
currentContext() {
|
|
230
|
+
return this.context;
|
|
231
|
+
}
|
|
232
|
+
// Sync flag access. Returns defaultValue with reason ERROR if the
|
|
233
|
+
// client isn't ready or the flag is missing. Sync because the cache
|
|
234
|
+
// is pre-computed; no async work happens in this path.
|
|
235
|
+
getDetail(flagKey, defaultValue) {
|
|
236
|
+
const cached = this.cache.get(flagKey);
|
|
237
|
+
if (cached !== void 0) return cached;
|
|
238
|
+
return {
|
|
239
|
+
value: defaultValue,
|
|
240
|
+
variationId: null,
|
|
241
|
+
reason: "ERROR",
|
|
242
|
+
errorMessage: this.context ? "flag could not be evaluated" : "client not ready: call setContext() and await ready()"
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
getValue(flagKey, defaultValue) {
|
|
246
|
+
return this.getDetail(flagKey, defaultValue).value;
|
|
247
|
+
}
|
|
248
|
+
getBooleanValue(flagKey, defaultValue) {
|
|
249
|
+
const v = this.getDetail(flagKey, defaultValue).value;
|
|
250
|
+
return typeof v === "boolean" ? v : defaultValue;
|
|
251
|
+
}
|
|
252
|
+
getStringValue(flagKey, defaultValue) {
|
|
253
|
+
const v = this.getDetail(flagKey, defaultValue).value;
|
|
254
|
+
return typeof v === "string" ? v : defaultValue;
|
|
255
|
+
}
|
|
256
|
+
getNumberValue(flagKey, defaultValue) {
|
|
257
|
+
const v = this.getDetail(flagKey, defaultValue).value;
|
|
258
|
+
return typeof v === "number" ? v : defaultValue;
|
|
259
|
+
}
|
|
260
|
+
getObjectValue(flagKey, defaultValue) {
|
|
261
|
+
const v = this.getDetail(flagKey, defaultValue).value;
|
|
262
|
+
return typeof v === "object" && v !== null ? v : defaultValue;
|
|
263
|
+
}
|
|
264
|
+
// Snapshot of the current sync cache. Useful for devtools and debug panels.
|
|
265
|
+
allFlags() {
|
|
266
|
+
return new Map(this.cache);
|
|
267
|
+
}
|
|
268
|
+
on(event, listener) {
|
|
269
|
+
return this.emitter.on(event, listener);
|
|
270
|
+
}
|
|
271
|
+
off(event, listener) {
|
|
272
|
+
this.emitter.off(event, listener);
|
|
273
|
+
}
|
|
274
|
+
// Force a one-shot fetch. Returns true if the in-memory datafile changed.
|
|
275
|
+
async refresh() {
|
|
276
|
+
return this.fetchDatafile();
|
|
277
|
+
}
|
|
278
|
+
currentDatafile() {
|
|
279
|
+
return this.datafile;
|
|
280
|
+
}
|
|
281
|
+
close() {
|
|
282
|
+
this.closed = true;
|
|
283
|
+
if (this.timer) {
|
|
284
|
+
clearInterval(this.timer);
|
|
285
|
+
this.timer = null;
|
|
286
|
+
}
|
|
287
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
288
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
289
|
+
this.visibilityHandler = null;
|
|
290
|
+
}
|
|
291
|
+
this.broadcast?.close();
|
|
292
|
+
this.broadcast = null;
|
|
293
|
+
this.emitter.removeAll();
|
|
294
|
+
}
|
|
295
|
+
async bootstrap() {
|
|
296
|
+
try {
|
|
297
|
+
if (this.datafile) await this.recomputeCache();
|
|
298
|
+
await this.fetchDatafile();
|
|
299
|
+
if (this.closed) return;
|
|
300
|
+
this.startPolling();
|
|
301
|
+
this.attachVisibilityHandler();
|
|
302
|
+
this.emitter.emit("ready", void 0);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
startPolling() {
|
|
309
|
+
if (this.timer) clearInterval(this.timer);
|
|
310
|
+
this.timer = setInterval(() => {
|
|
311
|
+
void this.fetchDatafile().catch((err) => {
|
|
312
|
+
console.warn("feat: background poll failed:", messageOf(err));
|
|
313
|
+
});
|
|
314
|
+
}, this.pollIntervalMs);
|
|
315
|
+
}
|
|
316
|
+
attachVisibilityHandler() {
|
|
317
|
+
if (typeof document === "undefined") return;
|
|
318
|
+
this.visibilityHandler = () => {
|
|
319
|
+
if (document.hidden) {
|
|
320
|
+
if (this.timer) {
|
|
321
|
+
clearInterval(this.timer);
|
|
322
|
+
this.timer = null;
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
void this.fetchDatafile().catch((err) => {
|
|
327
|
+
console.warn("feat: visibility refresh failed:", messageOf(err));
|
|
328
|
+
});
|
|
329
|
+
if (!this.timer) this.startPolling();
|
|
330
|
+
};
|
|
331
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
332
|
+
}
|
|
333
|
+
async fetchDatafile() {
|
|
334
|
+
const url = `${this.config.dataPlaneUrl.replace(/\/$/, "")}/sdk/v1/datafile`;
|
|
335
|
+
const headers = {
|
|
336
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
337
|
+
};
|
|
338
|
+
if (this.etag) headers["If-None-Match"] = this.etag;
|
|
339
|
+
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
340
|
+
if (res.status === 304) return false;
|
|
341
|
+
if (res.status === 404) return false;
|
|
342
|
+
if (res.status === 429) return false;
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
throw new Error(`fetchDatafile failed: ${res.status}`);
|
|
345
|
+
}
|
|
346
|
+
const lengthHeader = res.headers.get("content-length");
|
|
347
|
+
if (lengthHeader && Number(lengthHeader) > MAX_DATAFILE_BYTES) {
|
|
348
|
+
throw new Error("datafile exceeds maximum allowed size");
|
|
349
|
+
}
|
|
350
|
+
const next = await res.json();
|
|
351
|
+
this.datafile = next;
|
|
352
|
+
this.etag = res.headers.get("etag");
|
|
353
|
+
if (this.config.cache) {
|
|
354
|
+
saveCachedDatafile(this.config.cache, { datafile: next, etag: this.etag });
|
|
355
|
+
}
|
|
356
|
+
this.broadcast?.publish(next, this.etag);
|
|
357
|
+
await this.recomputeCache();
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
// Sibling-tab handler. Only adopt if the broadcast carries a newer
|
|
361
|
+
// version (or we have nothing); old broadcasts can race with our own
|
|
362
|
+
// fresh fetches and we don't want to regress.
|
|
363
|
+
async adoptFromBroadcast(datafile, etag) {
|
|
364
|
+
if (this.datafile && datafile.version <= this.datafile.version) return;
|
|
365
|
+
this.datafile = datafile;
|
|
366
|
+
this.etag = etag;
|
|
367
|
+
if (this.config.cache) {
|
|
368
|
+
saveCachedDatafile(this.config.cache, { datafile, etag });
|
|
369
|
+
}
|
|
370
|
+
await this.recomputeCache();
|
|
371
|
+
}
|
|
372
|
+
// Pre-evaluate every flag in the datafile against the current context
|
|
373
|
+
// and diff against the previous cache. Skips silently if datafile or
|
|
374
|
+
// context is missing; the caller will recompute as soon as both land.
|
|
375
|
+
async recomputeCache() {
|
|
376
|
+
if (!this.datafile || !this.context) return;
|
|
377
|
+
const prev = this.cache;
|
|
378
|
+
const next = /* @__PURE__ */ new Map();
|
|
379
|
+
const datafile = this.datafile;
|
|
380
|
+
const context = this.context;
|
|
381
|
+
for (const flagKey of Object.keys(datafile.flags)) {
|
|
382
|
+
const flag = datafile.flags[flagKey];
|
|
383
|
+
if (!flag) continue;
|
|
384
|
+
const result = await featEval.evaluate(flagKey, null, context, datafile);
|
|
385
|
+
next.set(flagKey, result);
|
|
386
|
+
}
|
|
387
|
+
this.cache = next;
|
|
388
|
+
for (const [flagKey, newResult] of next) {
|
|
389
|
+
const oldResult = prev.get(flagKey);
|
|
390
|
+
if (!oldResult || !sameValue(oldResult.value, newResult.value)) {
|
|
391
|
+
this.emitter.emit("change", {
|
|
392
|
+
flagKey,
|
|
393
|
+
oldValue: oldResult?.value ?? null,
|
|
394
|
+
newValue: newResult.value,
|
|
395
|
+
oldVariation: oldResult?.variationId ?? null,
|
|
396
|
+
newVariation: newResult.variationId
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
for (const [flagKey, oldResult] of prev) {
|
|
401
|
+
if (!next.has(flagKey)) {
|
|
402
|
+
this.emitter.emit("change", {
|
|
403
|
+
flagKey,
|
|
404
|
+
oldValue: oldResult.value,
|
|
405
|
+
newValue: null,
|
|
406
|
+
oldVariation: oldResult.variationId,
|
|
407
|
+
newVariation: null
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
this.emitter.emit("update", void 0);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
function sameValue(a, b) {
|
|
415
|
+
if (a === b) return true;
|
|
416
|
+
try {
|
|
417
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
418
|
+
} catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function messageOf(err) {
|
|
423
|
+
return err instanceof Error ? err.message : String(err);
|
|
424
|
+
}
|
|
425
|
+
function assertHttpsUrl(url) {
|
|
426
|
+
try {
|
|
427
|
+
const u = new URL(url);
|
|
428
|
+
if (u.protocol === "https:") return;
|
|
429
|
+
if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1")) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
}
|
|
434
|
+
throw new Error("dataPlaneUrl must use https:// (http://localhost allowed for tests)");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/index.ts
|
|
438
|
+
var SDK_VERSION = "0.1.0";
|
|
439
|
+
|
|
440
|
+
exports.FeatWebClient = FeatWebClient;
|
|
441
|
+
exports.SDK_VERSION = SDK_VERSION;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Datafile } from '@feathq/datafile-schema';
|
|
2
|
+
export { Datafile } from '@feathq/datafile-schema';
|
|
3
|
+
import { EvalContext, EvaluationResult } from '@feathq/feat-eval';
|
|
4
|
+
export { ContextKindObject, EvalContext, EvaluationResult, Reason } from '@feathq/feat-eval';
|
|
5
|
+
|
|
6
|
+
type AnonymousStorage = "localStorage" | "memory";
|
|
7
|
+
interface AnonymousConfig {
|
|
8
|
+
storage: AnonymousStorage;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type DatafileCacheStorage = "localStorage";
|
|
12
|
+
interface DatafileCacheConfig {
|
|
13
|
+
storage: DatafileCacheStorage;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FeatWebClientConfig {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
dataPlaneUrl: string;
|
|
19
|
+
context?: EvalContext;
|
|
20
|
+
anonymous?: AnonymousConfig;
|
|
21
|
+
bootstrap?: Datafile;
|
|
22
|
+
cache?: DatafileCacheConfig;
|
|
23
|
+
pollIntervalMs?: number;
|
|
24
|
+
crossTabSync?: boolean;
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
interface ChangeEvent {
|
|
28
|
+
flagKey: string;
|
|
29
|
+
oldValue: unknown;
|
|
30
|
+
newValue: unknown;
|
|
31
|
+
oldVariation: string | null;
|
|
32
|
+
newVariation: string | null;
|
|
33
|
+
}
|
|
34
|
+
interface FlagEventMap {
|
|
35
|
+
ready: undefined;
|
|
36
|
+
update: undefined;
|
|
37
|
+
change: ChangeEvent;
|
|
38
|
+
failed: Error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare class FeatWebClient {
|
|
42
|
+
private readonly config;
|
|
43
|
+
private datafile;
|
|
44
|
+
private etag;
|
|
45
|
+
private context;
|
|
46
|
+
private cache;
|
|
47
|
+
private timer;
|
|
48
|
+
private readyPromise;
|
|
49
|
+
private visibilityHandler;
|
|
50
|
+
private emitter;
|
|
51
|
+
private broadcast;
|
|
52
|
+
private readonly fetchImpl;
|
|
53
|
+
private readonly pollIntervalMs;
|
|
54
|
+
private closed;
|
|
55
|
+
constructor(config: FeatWebClientConfig);
|
|
56
|
+
ready(): Promise<void>;
|
|
57
|
+
setContext(context: EvalContext): Promise<void>;
|
|
58
|
+
currentContext(): EvalContext | null;
|
|
59
|
+
getDetail<T = unknown>(flagKey: string, defaultValue: T): EvaluationResult<T>;
|
|
60
|
+
getValue<T = unknown>(flagKey: string, defaultValue: T): T;
|
|
61
|
+
getBooleanValue(flagKey: string, defaultValue: boolean): boolean;
|
|
62
|
+
getStringValue(flagKey: string, defaultValue: string): string;
|
|
63
|
+
getNumberValue(flagKey: string, defaultValue: number): number;
|
|
64
|
+
getObjectValue<T = unknown>(flagKey: string, defaultValue: T): T;
|
|
65
|
+
allFlags(): ReadonlyMap<string, EvaluationResult>;
|
|
66
|
+
on<K extends keyof FlagEventMap>(event: K, listener: (arg: FlagEventMap[K]) => void): () => void;
|
|
67
|
+
off<K extends keyof FlagEventMap>(event: K, listener: (arg: FlagEventMap[K]) => void): void;
|
|
68
|
+
refresh(): Promise<boolean>;
|
|
69
|
+
currentDatafile(): Datafile | null;
|
|
70
|
+
close(): void;
|
|
71
|
+
private bootstrap;
|
|
72
|
+
private startPolling;
|
|
73
|
+
private attachVisibilityHandler;
|
|
74
|
+
private fetchDatafile;
|
|
75
|
+
private adoptFromBroadcast;
|
|
76
|
+
private recomputeCache;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
declare const SDK_VERSION = "0.1.0";
|
|
80
|
+
|
|
81
|
+
export { type ChangeEvent, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Datafile } from '@feathq/datafile-schema';
|
|
2
|
+
export { Datafile } from '@feathq/datafile-schema';
|
|
3
|
+
import { EvalContext, EvaluationResult } from '@feathq/feat-eval';
|
|
4
|
+
export { ContextKindObject, EvalContext, EvaluationResult, Reason } from '@feathq/feat-eval';
|
|
5
|
+
|
|
6
|
+
type AnonymousStorage = "localStorage" | "memory";
|
|
7
|
+
interface AnonymousConfig {
|
|
8
|
+
storage: AnonymousStorage;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type DatafileCacheStorage = "localStorage";
|
|
12
|
+
interface DatafileCacheConfig {
|
|
13
|
+
storage: DatafileCacheStorage;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FeatWebClientConfig {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
dataPlaneUrl: string;
|
|
19
|
+
context?: EvalContext;
|
|
20
|
+
anonymous?: AnonymousConfig;
|
|
21
|
+
bootstrap?: Datafile;
|
|
22
|
+
cache?: DatafileCacheConfig;
|
|
23
|
+
pollIntervalMs?: number;
|
|
24
|
+
crossTabSync?: boolean;
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
interface ChangeEvent {
|
|
28
|
+
flagKey: string;
|
|
29
|
+
oldValue: unknown;
|
|
30
|
+
newValue: unknown;
|
|
31
|
+
oldVariation: string | null;
|
|
32
|
+
newVariation: string | null;
|
|
33
|
+
}
|
|
34
|
+
interface FlagEventMap {
|
|
35
|
+
ready: undefined;
|
|
36
|
+
update: undefined;
|
|
37
|
+
change: ChangeEvent;
|
|
38
|
+
failed: Error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare class FeatWebClient {
|
|
42
|
+
private readonly config;
|
|
43
|
+
private datafile;
|
|
44
|
+
private etag;
|
|
45
|
+
private context;
|
|
46
|
+
private cache;
|
|
47
|
+
private timer;
|
|
48
|
+
private readyPromise;
|
|
49
|
+
private visibilityHandler;
|
|
50
|
+
private emitter;
|
|
51
|
+
private broadcast;
|
|
52
|
+
private readonly fetchImpl;
|
|
53
|
+
private readonly pollIntervalMs;
|
|
54
|
+
private closed;
|
|
55
|
+
constructor(config: FeatWebClientConfig);
|
|
56
|
+
ready(): Promise<void>;
|
|
57
|
+
setContext(context: EvalContext): Promise<void>;
|
|
58
|
+
currentContext(): EvalContext | null;
|
|
59
|
+
getDetail<T = unknown>(flagKey: string, defaultValue: T): EvaluationResult<T>;
|
|
60
|
+
getValue<T = unknown>(flagKey: string, defaultValue: T): T;
|
|
61
|
+
getBooleanValue(flagKey: string, defaultValue: boolean): boolean;
|
|
62
|
+
getStringValue(flagKey: string, defaultValue: string): string;
|
|
63
|
+
getNumberValue(flagKey: string, defaultValue: number): number;
|
|
64
|
+
getObjectValue<T = unknown>(flagKey: string, defaultValue: T): T;
|
|
65
|
+
allFlags(): ReadonlyMap<string, EvaluationResult>;
|
|
66
|
+
on<K extends keyof FlagEventMap>(event: K, listener: (arg: FlagEventMap[K]) => void): () => void;
|
|
67
|
+
off<K extends keyof FlagEventMap>(event: K, listener: (arg: FlagEventMap[K]) => void): void;
|
|
68
|
+
refresh(): Promise<boolean>;
|
|
69
|
+
currentDatafile(): Datafile | null;
|
|
70
|
+
close(): void;
|
|
71
|
+
private bootstrap;
|
|
72
|
+
private startPolling;
|
|
73
|
+
private attachVisibilityHandler;
|
|
74
|
+
private fetchDatafile;
|
|
75
|
+
private adoptFromBroadcast;
|
|
76
|
+
private recomputeCache;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
declare const SDK_VERSION = "0.1.0";
|
|
80
|
+
|
|
81
|
+
export { type ChangeEvent, FeatWebClient, type FeatWebClientConfig, type FlagEventMap, SDK_VERSION };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { evaluate } from '@feathq/feat-eval';
|
|
2
|
+
|
|
3
|
+
// src/client.ts
|
|
4
|
+
|
|
5
|
+
// src/anonymous.ts
|
|
6
|
+
var STORAGE_KEY = "feat:anonymousKey";
|
|
7
|
+
function buildAnonymousContext(config) {
|
|
8
|
+
const key = resolveOrMintAnonymousKey(config.storage);
|
|
9
|
+
return {
|
|
10
|
+
targetingKey: key,
|
|
11
|
+
user: { key, anonymous: true }
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
var memoryKey = null;
|
|
15
|
+
function resolveOrMintAnonymousKey(storage) {
|
|
16
|
+
if (storage === "localStorage" && hasLocalStorage()) {
|
|
17
|
+
try {
|
|
18
|
+
const existing = window.localStorage.getItem(STORAGE_KEY);
|
|
19
|
+
if (existing) return existing;
|
|
20
|
+
const fresh = randomKey();
|
|
21
|
+
window.localStorage.setItem(STORAGE_KEY, fresh);
|
|
22
|
+
return fresh;
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (memoryKey) return memoryKey;
|
|
27
|
+
memoryKey = randomKey();
|
|
28
|
+
return memoryKey;
|
|
29
|
+
}
|
|
30
|
+
function hasLocalStorage() {
|
|
31
|
+
try {
|
|
32
|
+
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function randomKey() {
|
|
38
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
39
|
+
return crypto.randomUUID();
|
|
40
|
+
}
|
|
41
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
42
|
+
const buf = new Uint8Array(16);
|
|
43
|
+
crypto.getRandomValues(buf);
|
|
44
|
+
buf[6] = (buf[6] ?? 0) & 15 | 64;
|
|
45
|
+
buf[8] = (buf[8] ?? 0) & 63 | 128;
|
|
46
|
+
return formatUuid(buf);
|
|
47
|
+
}
|
|
48
|
+
let s = "";
|
|
49
|
+
for (let i = 0; i < 32; i++) {
|
|
50
|
+
s += Math.floor(Math.random() * 16).toString(16);
|
|
51
|
+
}
|
|
52
|
+
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
|
|
53
|
+
}
|
|
54
|
+
function formatUuid(bytes) {
|
|
55
|
+
const hex = [];
|
|
56
|
+
for (let i = 0; i < 16; i++) {
|
|
57
|
+
hex.push(((bytes[i] ?? 0) & 255).toString(16).padStart(2, "0"));
|
|
58
|
+
}
|
|
59
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/broadcast.ts
|
|
63
|
+
var CHANNEL_NAME = "feat:datafile";
|
|
64
|
+
var DatafileBroadcast = class {
|
|
65
|
+
constructor(handler) {
|
|
66
|
+
this.channel = null;
|
|
67
|
+
this.handler = null;
|
|
68
|
+
this.listener = null;
|
|
69
|
+
if (typeof BroadcastChannel === "undefined") return;
|
|
70
|
+
this.handler = handler;
|
|
71
|
+
this.channel = new BroadcastChannel(CHANNEL_NAME);
|
|
72
|
+
this.listener = (ev) => {
|
|
73
|
+
const data = ev.data;
|
|
74
|
+
if (!data || data.type !== "datafile-update" || !this.handler) return;
|
|
75
|
+
this.handler(data);
|
|
76
|
+
};
|
|
77
|
+
this.channel.addEventListener("message", this.listener);
|
|
78
|
+
}
|
|
79
|
+
publish(datafile, etag) {
|
|
80
|
+
if (!this.channel) return;
|
|
81
|
+
const msg = { type: "datafile-update", datafile, etag };
|
|
82
|
+
try {
|
|
83
|
+
this.channel.postMessage(msg);
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
close() {
|
|
88
|
+
if (this.channel && this.listener) {
|
|
89
|
+
this.channel.removeEventListener("message", this.listener);
|
|
90
|
+
}
|
|
91
|
+
this.channel?.close();
|
|
92
|
+
this.channel = null;
|
|
93
|
+
this.handler = null;
|
|
94
|
+
this.listener = null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/emitter.ts
|
|
99
|
+
var Emitter = class {
|
|
100
|
+
constructor() {
|
|
101
|
+
this.listeners = {};
|
|
102
|
+
}
|
|
103
|
+
on(event, listener) {
|
|
104
|
+
let set = this.listeners[event];
|
|
105
|
+
if (!set) {
|
|
106
|
+
set = /* @__PURE__ */ new Set();
|
|
107
|
+
this.listeners[event] = set;
|
|
108
|
+
}
|
|
109
|
+
set.add(listener);
|
|
110
|
+
return () => this.off(event, listener);
|
|
111
|
+
}
|
|
112
|
+
off(event, listener) {
|
|
113
|
+
this.listeners[event]?.delete(listener);
|
|
114
|
+
}
|
|
115
|
+
emit(event, arg) {
|
|
116
|
+
const set = this.listeners[event];
|
|
117
|
+
if (!set) return;
|
|
118
|
+
for (const listener of [...set]) {
|
|
119
|
+
try {
|
|
120
|
+
listener(arg);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error("feat-web-sdk: emitter listener threw", err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
removeAll() {
|
|
127
|
+
this.listeners = {};
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// src/persistence.ts
|
|
132
|
+
var STORAGE_KEY2 = "feat:datafile";
|
|
133
|
+
function loadCachedDatafile(config) {
|
|
134
|
+
if (config.storage !== "localStorage" || !hasLocalStorage2()) return null;
|
|
135
|
+
try {
|
|
136
|
+
const raw = window.localStorage.getItem(STORAGE_KEY2);
|
|
137
|
+
if (!raw) return null;
|
|
138
|
+
const parsed = JSON.parse(raw);
|
|
139
|
+
if (!parsed.datafile || typeof parsed.datafile !== "object") return null;
|
|
140
|
+
const df = parsed.datafile;
|
|
141
|
+
if (typeof df.envId !== "string" || typeof df.version !== "number") return null;
|
|
142
|
+
return { datafile: df, etag: parsed.etag ?? null };
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function saveCachedDatafile(config, payload) {
|
|
148
|
+
if (config.storage !== "localStorage" || !hasLocalStorage2()) return;
|
|
149
|
+
try {
|
|
150
|
+
window.localStorage.setItem(STORAGE_KEY2, JSON.stringify(payload));
|
|
151
|
+
} catch {
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function hasLocalStorage2() {
|
|
155
|
+
try {
|
|
156
|
+
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/client.ts
|
|
163
|
+
var CLIENT_SIDE_PREFIX = "feat_cs_";
|
|
164
|
+
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
165
|
+
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
166
|
+
var MAX_DATAFILE_BYTES = 10 * 1024 * 1024;
|
|
167
|
+
var FeatWebClient = class {
|
|
168
|
+
constructor(config) {
|
|
169
|
+
this.config = config;
|
|
170
|
+
this.datafile = null;
|
|
171
|
+
this.etag = null;
|
|
172
|
+
this.context = null;
|
|
173
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
174
|
+
this.timer = null;
|
|
175
|
+
this.readyPromise = null;
|
|
176
|
+
this.visibilityHandler = null;
|
|
177
|
+
this.emitter = new Emitter();
|
|
178
|
+
this.broadcast = null;
|
|
179
|
+
this.closed = false;
|
|
180
|
+
if (!config.apiKey.startsWith(CLIENT_SIDE_PREFIX)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`FeatWebClient requires a client_side_id key (prefix "${CLIENT_SIDE_PREFIX}"). Server and mobile keys must never ship in browser code.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
assertHttpsUrl(config.dataPlaneUrl);
|
|
186
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
187
|
+
this.pollIntervalMs = Math.max(
|
|
188
|
+
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
189
|
+
MIN_POLL_INTERVAL_MS
|
|
190
|
+
);
|
|
191
|
+
if (config.context) {
|
|
192
|
+
this.context = config.context;
|
|
193
|
+
} else if (config.anonymous) {
|
|
194
|
+
this.context = buildAnonymousContext(config.anonymous);
|
|
195
|
+
}
|
|
196
|
+
if (config.bootstrap) {
|
|
197
|
+
this.datafile = config.bootstrap;
|
|
198
|
+
this.etag = config.bootstrap.etag;
|
|
199
|
+
} else if (config.cache) {
|
|
200
|
+
const cached = loadCachedDatafile(config.cache);
|
|
201
|
+
if (cached) {
|
|
202
|
+
this.datafile = cached.datafile;
|
|
203
|
+
this.etag = cached.etag;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (config.crossTabSync !== false) {
|
|
207
|
+
this.broadcast = new DatafileBroadcast((msg) => {
|
|
208
|
+
void this.adoptFromBroadcast(msg.datafile, msg.etag);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Resolves once the first datafile is in memory AND (if context was
|
|
213
|
+
// supplied) the cache has been pre-evaluated.
|
|
214
|
+
async ready() {
|
|
215
|
+
if (!this.readyPromise) {
|
|
216
|
+
this.readyPromise = this.bootstrap();
|
|
217
|
+
}
|
|
218
|
+
return this.readyPromise;
|
|
219
|
+
}
|
|
220
|
+
// Swap the evaluation context. Re-evaluates every flag, diffs against
|
|
221
|
+
// the previous cache, and fires a `change` event per flipped flag.
|
|
222
|
+
// OpenFeature's `onContextChange` lifecycle hook bridges to this.
|
|
223
|
+
async setContext(context) {
|
|
224
|
+
this.context = context;
|
|
225
|
+
await this.recomputeCache();
|
|
226
|
+
}
|
|
227
|
+
currentContext() {
|
|
228
|
+
return this.context;
|
|
229
|
+
}
|
|
230
|
+
// Sync flag access. Returns defaultValue with reason ERROR if the
|
|
231
|
+
// client isn't ready or the flag is missing. Sync because the cache
|
|
232
|
+
// is pre-computed; no async work happens in this path.
|
|
233
|
+
getDetail(flagKey, defaultValue) {
|
|
234
|
+
const cached = this.cache.get(flagKey);
|
|
235
|
+
if (cached !== void 0) return cached;
|
|
236
|
+
return {
|
|
237
|
+
value: defaultValue,
|
|
238
|
+
variationId: null,
|
|
239
|
+
reason: "ERROR",
|
|
240
|
+
errorMessage: this.context ? "flag could not be evaluated" : "client not ready: call setContext() and await ready()"
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
getValue(flagKey, defaultValue) {
|
|
244
|
+
return this.getDetail(flagKey, defaultValue).value;
|
|
245
|
+
}
|
|
246
|
+
getBooleanValue(flagKey, defaultValue) {
|
|
247
|
+
const v = this.getDetail(flagKey, defaultValue).value;
|
|
248
|
+
return typeof v === "boolean" ? v : defaultValue;
|
|
249
|
+
}
|
|
250
|
+
getStringValue(flagKey, defaultValue) {
|
|
251
|
+
const v = this.getDetail(flagKey, defaultValue).value;
|
|
252
|
+
return typeof v === "string" ? v : defaultValue;
|
|
253
|
+
}
|
|
254
|
+
getNumberValue(flagKey, defaultValue) {
|
|
255
|
+
const v = this.getDetail(flagKey, defaultValue).value;
|
|
256
|
+
return typeof v === "number" ? v : defaultValue;
|
|
257
|
+
}
|
|
258
|
+
getObjectValue(flagKey, defaultValue) {
|
|
259
|
+
const v = this.getDetail(flagKey, defaultValue).value;
|
|
260
|
+
return typeof v === "object" && v !== null ? v : defaultValue;
|
|
261
|
+
}
|
|
262
|
+
// Snapshot of the current sync cache. Useful for devtools and debug panels.
|
|
263
|
+
allFlags() {
|
|
264
|
+
return new Map(this.cache);
|
|
265
|
+
}
|
|
266
|
+
on(event, listener) {
|
|
267
|
+
return this.emitter.on(event, listener);
|
|
268
|
+
}
|
|
269
|
+
off(event, listener) {
|
|
270
|
+
this.emitter.off(event, listener);
|
|
271
|
+
}
|
|
272
|
+
// Force a one-shot fetch. Returns true if the in-memory datafile changed.
|
|
273
|
+
async refresh() {
|
|
274
|
+
return this.fetchDatafile();
|
|
275
|
+
}
|
|
276
|
+
currentDatafile() {
|
|
277
|
+
return this.datafile;
|
|
278
|
+
}
|
|
279
|
+
close() {
|
|
280
|
+
this.closed = true;
|
|
281
|
+
if (this.timer) {
|
|
282
|
+
clearInterval(this.timer);
|
|
283
|
+
this.timer = null;
|
|
284
|
+
}
|
|
285
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
286
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
287
|
+
this.visibilityHandler = null;
|
|
288
|
+
}
|
|
289
|
+
this.broadcast?.close();
|
|
290
|
+
this.broadcast = null;
|
|
291
|
+
this.emitter.removeAll();
|
|
292
|
+
}
|
|
293
|
+
async bootstrap() {
|
|
294
|
+
try {
|
|
295
|
+
if (this.datafile) await this.recomputeCache();
|
|
296
|
+
await this.fetchDatafile();
|
|
297
|
+
if (this.closed) return;
|
|
298
|
+
this.startPolling();
|
|
299
|
+
this.attachVisibilityHandler();
|
|
300
|
+
this.emitter.emit("ready", void 0);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
this.emitter.emit("failed", err instanceof Error ? err : new Error(String(err)));
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
startPolling() {
|
|
307
|
+
if (this.timer) clearInterval(this.timer);
|
|
308
|
+
this.timer = setInterval(() => {
|
|
309
|
+
void this.fetchDatafile().catch((err) => {
|
|
310
|
+
console.warn("feat: background poll failed:", messageOf(err));
|
|
311
|
+
});
|
|
312
|
+
}, this.pollIntervalMs);
|
|
313
|
+
}
|
|
314
|
+
attachVisibilityHandler() {
|
|
315
|
+
if (typeof document === "undefined") return;
|
|
316
|
+
this.visibilityHandler = () => {
|
|
317
|
+
if (document.hidden) {
|
|
318
|
+
if (this.timer) {
|
|
319
|
+
clearInterval(this.timer);
|
|
320
|
+
this.timer = null;
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
void this.fetchDatafile().catch((err) => {
|
|
325
|
+
console.warn("feat: visibility refresh failed:", messageOf(err));
|
|
326
|
+
});
|
|
327
|
+
if (!this.timer) this.startPolling();
|
|
328
|
+
};
|
|
329
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
330
|
+
}
|
|
331
|
+
async fetchDatafile() {
|
|
332
|
+
const url = `${this.config.dataPlaneUrl.replace(/\/$/, "")}/sdk/v1/datafile`;
|
|
333
|
+
const headers = {
|
|
334
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
335
|
+
};
|
|
336
|
+
if (this.etag) headers["If-None-Match"] = this.etag;
|
|
337
|
+
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
338
|
+
if (res.status === 304) return false;
|
|
339
|
+
if (res.status === 404) return false;
|
|
340
|
+
if (res.status === 429) return false;
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
throw new Error(`fetchDatafile failed: ${res.status}`);
|
|
343
|
+
}
|
|
344
|
+
const lengthHeader = res.headers.get("content-length");
|
|
345
|
+
if (lengthHeader && Number(lengthHeader) > MAX_DATAFILE_BYTES) {
|
|
346
|
+
throw new Error("datafile exceeds maximum allowed size");
|
|
347
|
+
}
|
|
348
|
+
const next = await res.json();
|
|
349
|
+
this.datafile = next;
|
|
350
|
+
this.etag = res.headers.get("etag");
|
|
351
|
+
if (this.config.cache) {
|
|
352
|
+
saveCachedDatafile(this.config.cache, { datafile: next, etag: this.etag });
|
|
353
|
+
}
|
|
354
|
+
this.broadcast?.publish(next, this.etag);
|
|
355
|
+
await this.recomputeCache();
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
// Sibling-tab handler. Only adopt if the broadcast carries a newer
|
|
359
|
+
// version (or we have nothing); old broadcasts can race with our own
|
|
360
|
+
// fresh fetches and we don't want to regress.
|
|
361
|
+
async adoptFromBroadcast(datafile, etag) {
|
|
362
|
+
if (this.datafile && datafile.version <= this.datafile.version) return;
|
|
363
|
+
this.datafile = datafile;
|
|
364
|
+
this.etag = etag;
|
|
365
|
+
if (this.config.cache) {
|
|
366
|
+
saveCachedDatafile(this.config.cache, { datafile, etag });
|
|
367
|
+
}
|
|
368
|
+
await this.recomputeCache();
|
|
369
|
+
}
|
|
370
|
+
// Pre-evaluate every flag in the datafile against the current context
|
|
371
|
+
// and diff against the previous cache. Skips silently if datafile or
|
|
372
|
+
// context is missing; the caller will recompute as soon as both land.
|
|
373
|
+
async recomputeCache() {
|
|
374
|
+
if (!this.datafile || !this.context) return;
|
|
375
|
+
const prev = this.cache;
|
|
376
|
+
const next = /* @__PURE__ */ new Map();
|
|
377
|
+
const datafile = this.datafile;
|
|
378
|
+
const context = this.context;
|
|
379
|
+
for (const flagKey of Object.keys(datafile.flags)) {
|
|
380
|
+
const flag = datafile.flags[flagKey];
|
|
381
|
+
if (!flag) continue;
|
|
382
|
+
const result = await evaluate(flagKey, null, context, datafile);
|
|
383
|
+
next.set(flagKey, result);
|
|
384
|
+
}
|
|
385
|
+
this.cache = next;
|
|
386
|
+
for (const [flagKey, newResult] of next) {
|
|
387
|
+
const oldResult = prev.get(flagKey);
|
|
388
|
+
if (!oldResult || !sameValue(oldResult.value, newResult.value)) {
|
|
389
|
+
this.emitter.emit("change", {
|
|
390
|
+
flagKey,
|
|
391
|
+
oldValue: oldResult?.value ?? null,
|
|
392
|
+
newValue: newResult.value,
|
|
393
|
+
oldVariation: oldResult?.variationId ?? null,
|
|
394
|
+
newVariation: newResult.variationId
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const [flagKey, oldResult] of prev) {
|
|
399
|
+
if (!next.has(flagKey)) {
|
|
400
|
+
this.emitter.emit("change", {
|
|
401
|
+
flagKey,
|
|
402
|
+
oldValue: oldResult.value,
|
|
403
|
+
newValue: null,
|
|
404
|
+
oldVariation: oldResult.variationId,
|
|
405
|
+
newVariation: null
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
this.emitter.emit("update", void 0);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
function sameValue(a, b) {
|
|
413
|
+
if (a === b) return true;
|
|
414
|
+
try {
|
|
415
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function messageOf(err) {
|
|
421
|
+
return err instanceof Error ? err.message : String(err);
|
|
422
|
+
}
|
|
423
|
+
function assertHttpsUrl(url) {
|
|
424
|
+
try {
|
|
425
|
+
const u = new URL(url);
|
|
426
|
+
if (u.protocol === "https:") return;
|
|
427
|
+
if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1")) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
}
|
|
432
|
+
throw new Error("dataPlaneUrl must use https:// (http://localhost allowed for tests)");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/index.ts
|
|
436
|
+
var SDK_VERSION = "0.1.0";
|
|
437
|
+
|
|
438
|
+
export { FeatWebClient, SDK_VERSION };
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@feathq/web-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "feat feature-flag SDK for browsers. Polling client + sync evaluation cache. Pair with @feathq/openfeature-web for OpenFeature.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"feature-flags",
|
|
7
|
+
"feature-toggles",
|
|
8
|
+
"openfeature",
|
|
9
|
+
"feat",
|
|
10
|
+
"browser",
|
|
11
|
+
"client-side"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "feat HQ <engineering@feat.so>",
|
|
15
|
+
"homepage": "https://feat.so",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/feathq/web-sdk"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/feathq/web-sdk/issues"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"packageManager": "yarn@4.14.1",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"main": "./dist/index.cjs",
|
|
27
|
+
"module": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"require": "./dist/index.cjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "vitest --run"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@feathq/datafile-schema": "^0.1.0",
|
|
49
|
+
"@feathq/feat-eval": "^0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"resolutions": {
|
|
52
|
+
"@feathq/datafile-schema": "portal:../../packages/datafile-schema",
|
|
53
|
+
"@feathq/datafile-schema@workspace:*": "portal:../../packages/datafile-schema",
|
|
54
|
+
"@feathq/feat-eval": "portal:../../packages/feat-eval"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^25.6.2",
|
|
58
|
+
"happy-dom": "^15.11.7",
|
|
59
|
+
"tsup": "^8.3.5",
|
|
60
|
+
"typescript": "^6.0.3",
|
|
61
|
+
"vitest": "^4.1.6"
|
|
62
|
+
}
|
|
63
|
+
}
|