@canarygate/sdk 0.1.3 → 0.1.4

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
@@ -65,7 +65,7 @@ Rollout evaluation hashes `userId` deterministically — the same user always ge
65
65
 
66
66
  ## Options
67
67
 
68
- The API base URL is resolved automatically from the `CANARYGATE_BASE_URL` environment variable (in browsers, `NEXT_PUBLIC_CANARYGATE_BASE_URL`), falling back to `http://localhost:3001`.
68
+ The API base URL is resolved automatically from the `CANARYGATE_BASE_URL` environment variable (in browsers, `NEXT_PUBLIC_CANARYGATE_BASE_URL`), falling back to `https://api.canarygate.dev`. Set the variable when self-hosting or during local development.
69
69
 
70
70
  | Option | Type | Default | Description |
71
71
  | -------------------- | --------- | ----------------------- | ------------------------------------------------ |
@@ -0,0 +1,341 @@
1
+ // src/hash.ts
2
+ function hashString(input) {
3
+ let hash = 5381;
4
+ for (let i = 0; i < input.length; i++) {
5
+ hash = (hash << 5) + hash ^ input.charCodeAt(i);
6
+ hash = hash >>> 0;
7
+ }
8
+ return hash % 100;
9
+ }
10
+
11
+ // src/sse.ts
12
+ function parseSseEventBlock(block) {
13
+ let event = "message";
14
+ const dataLines = [];
15
+ let retryMs;
16
+ for (const line of block.split(/\r?\n/)) {
17
+ if (!line || line.startsWith(":")) continue;
18
+ const separatorIndex = line.indexOf(":");
19
+ const field = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
20
+ const value = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1).trimStart();
21
+ if (field === "event") {
22
+ event = value || "message";
23
+ continue;
24
+ }
25
+ if (field === "data") {
26
+ dataLines.push(value);
27
+ continue;
28
+ }
29
+ if (field === "retry") {
30
+ const parsedRetryMs = Number.parseInt(value, 10);
31
+ if (Number.isFinite(parsedRetryMs) && parsedRetryMs > 0) {
32
+ retryMs = parsedRetryMs;
33
+ }
34
+ }
35
+ }
36
+ if (dataLines.length === 0 && retryMs === void 0) {
37
+ return null;
38
+ }
39
+ return { event, data: dataLines.join("\n"), retryMs };
40
+ }
41
+
42
+ // src/canary-gate-base.ts
43
+ var DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
44
+ var DEFAULT_HEARTBEAT_TIMEOUT_MS = 65e3;
45
+ var DEFAULT_POLL_INTERVAL_MS = 3e4;
46
+ var MIN_POLL_INTERVAL_MS = 3e3;
47
+ var DEFAULT_BASE_URL = "https://api.canarygate.dev";
48
+ function resolveBaseUrl() {
49
+ if (typeof process !== "undefined" && typeof process.env === "object" && process.env !== null) {
50
+ return (process.env.CANARYGATE_BASE_URL ?? process.env.NEXT_PUBLIC_CANARYGATE_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
51
+ }
52
+ return DEFAULT_BASE_URL;
53
+ }
54
+ function isAbortError(error) {
55
+ return error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError";
56
+ }
57
+ function parseTimestamp(value) {
58
+ const parsed = Date.parse(value);
59
+ return Number.isNaN(parsed) ? 0 : parsed;
60
+ }
61
+ var CanaryGateBase = class {
62
+ constructor(apiKey, options = {}, streamEnabled, anonIdFactory) {
63
+ this.apiKey = apiKey;
64
+ this.streamEnabled = streamEnabled;
65
+ this.anonIdFactory = anonIdFactory;
66
+ this.cache = /* @__PURE__ */ new Map();
67
+ this.cacheVersions = /* @__PURE__ */ new Map();
68
+ this.streamAbortController = null;
69
+ this.reconnectTimeout = null;
70
+ this.heartbeatTimeout = null;
71
+ this.pollTimeout = null;
72
+ this.reconnectAttempts = 0;
73
+ this.stale = false;
74
+ this.lastSyncAt = null;
75
+ this.destroyed = false;
76
+ this.baseUrl = resolveBaseUrl();
77
+ this.environment = options.environment;
78
+ this.reconnectDelay = options.reconnectDelay ?? 5e3;
79
+ this.maxReconnectDelay = Math.max(
80
+ options.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY_MS,
81
+ this.reconnectDelay
82
+ );
83
+ this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
84
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
85
+ if (options.pollIntervalMs !== void 0 && options.pollIntervalMs > 0 && options.pollIntervalMs < MIN_POLL_INTERVAL_MS) {
86
+ console.warn(
87
+ `[canarygate] pollIntervalMs must be at least ${MIN_POLL_INTERVAL_MS}ms; clamped to ${MIN_POLL_INTERVAL_MS}.`
88
+ );
89
+ this.pollIntervalMs = MIN_POLL_INTERVAL_MS;
90
+ }
91
+ this.streamRetryDelay = this.reconnectDelay;
92
+ this.anonId = anonIdFactory();
93
+ }
94
+ async init() {
95
+ await this.fetchFlags();
96
+ if (this.streamEnabled) {
97
+ this.connectStream();
98
+ } else {
99
+ this.startPolling();
100
+ }
101
+ }
102
+ replaceCacheFromSnapshot(flags, requestedAt) {
103
+ const nextCache = /* @__PURE__ */ new Map();
104
+ const nextVersions = /* @__PURE__ */ new Map();
105
+ for (const flag of flags) {
106
+ const nextVersion = parseTimestamp(flag.updatedAt);
107
+ const currentVersion = this.cacheVersions.get(flag.key) ?? -1;
108
+ if (currentVersion > nextVersion && currentVersion > requestedAt) {
109
+ const currentFlag = this.cache.get(flag.key);
110
+ if (currentFlag) nextCache.set(flag.key, currentFlag);
111
+ nextVersions.set(flag.key, currentVersion);
112
+ continue;
113
+ }
114
+ nextCache.set(flag.key, flag);
115
+ nextVersions.set(flag.key, nextVersion);
116
+ }
117
+ for (const [key, currentVersion] of this.cacheVersions) {
118
+ if (nextVersions.has(key) || currentVersion <= requestedAt) {
119
+ continue;
120
+ }
121
+ const currentFlag = this.cache.get(key);
122
+ if (currentFlag) nextCache.set(key, currentFlag);
123
+ nextVersions.set(key, currentVersion);
124
+ }
125
+ this.cache = nextCache;
126
+ this.cacheVersions = nextVersions;
127
+ this.stale = false;
128
+ this.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
129
+ }
130
+ async fetchFlags() {
131
+ const requestedAt = Date.now();
132
+ try {
133
+ const headers = { "X-Api-Key": this.apiKey };
134
+ if (this.environment) headers["X-Environment"] = this.environment;
135
+ const res = await fetch(`${this.baseUrl}/sdk/flags`, { headers });
136
+ if (!res.ok) {
137
+ console.error(
138
+ `[canarygate] Failed to fetch flags: ${res.status} ${res.statusText}`
139
+ );
140
+ this.stale = true;
141
+ return false;
142
+ }
143
+ const body = await res.json();
144
+ this.replaceCacheFromSnapshot(body.flags, requestedAt);
145
+ return true;
146
+ } catch (err) {
147
+ console.error("[canarygate] Error fetching flags:", err);
148
+ this.stale = true;
149
+ return false;
150
+ }
151
+ }
152
+ applyFlagUpdate(raw) {
153
+ const nextVersion = parseTimestamp(raw.updatedAt);
154
+ const currentVersion = this.cacheVersions.get(raw.key) ?? -1;
155
+ if (nextVersion < currentVersion) return;
156
+ this.cacheVersions.set(raw.key, nextVersion);
157
+ this.cache.set(raw.key, raw);
158
+ }
159
+ applyFlagDeletion(payload) {
160
+ const nextVersion = parseTimestamp(payload.deletedAt);
161
+ const currentVersion = this.cacheVersions.get(payload.key) ?? -1;
162
+ if (nextVersion < currentVersion) return;
163
+ this.cacheVersions.set(payload.key, nextVersion);
164
+ this.cache.delete(payload.key);
165
+ }
166
+ handleStreamMessage(event, data) {
167
+ if (event === "connected" || event === "connection-closing") return;
168
+ if (!data) return;
169
+ try {
170
+ if (event === "flag-deleted") {
171
+ const payload = JSON.parse(data);
172
+ this.applyFlagDeletion(payload);
173
+ return;
174
+ }
175
+ if (event === "flag-updated" || event === "flag-created") {
176
+ this.applyFlagUpdate(JSON.parse(data));
177
+ }
178
+ } catch (err) {
179
+ console.error(`[canarygate] Failed to parse ${event} event:`, err);
180
+ }
181
+ }
182
+ clearHeartbeatTimeout() {
183
+ if (this.heartbeatTimeout) {
184
+ clearTimeout(this.heartbeatTimeout);
185
+ this.heartbeatTimeout = null;
186
+ }
187
+ }
188
+ bumpHeartbeatTimeout(abortController) {
189
+ this.clearHeartbeatTimeout();
190
+ this.heartbeatTimeout = setTimeout(() => {
191
+ if (this.streamAbortController === abortController && !this.destroyed) {
192
+ abortController.abort();
193
+ }
194
+ }, this.heartbeatTimeoutMs);
195
+ }
196
+ scheduleReconnect() {
197
+ if (this.destroyed || this.reconnectTimeout) return;
198
+ const nextDelay = Math.min(
199
+ this.streamRetryDelay * 2 ** this.reconnectAttempts,
200
+ this.maxReconnectDelay
201
+ );
202
+ this.reconnectAttempts += 1;
203
+ this.reconnectTimeout = setTimeout(() => {
204
+ this.reconnectTimeout = null;
205
+ this.connectStream();
206
+ }, nextDelay);
207
+ }
208
+ async consumeStream(abortController) {
209
+ try {
210
+ const headers = { "X-Api-Key": this.apiKey };
211
+ if (this.environment) headers["X-Environment"] = this.environment;
212
+ const response = await fetch(`${this.baseUrl}/sdk/stream`, {
213
+ headers,
214
+ signal: abortController.signal,
215
+ cache: "no-store"
216
+ });
217
+ if (!response.ok) {
218
+ console.error(
219
+ `[canarygate] Failed to connect stream: ${response.status} ${response.statusText}`
220
+ );
221
+ this.stale = true;
222
+ return;
223
+ }
224
+ if (!response.body) {
225
+ console.error(
226
+ "[canarygate] Stream body is not available in this runtime"
227
+ );
228
+ this.stale = true;
229
+ return;
230
+ }
231
+ this.reconnectAttempts = 0;
232
+ this.bumpHeartbeatTimeout(abortController);
233
+ if (this.stale) {
234
+ await this.fetchFlags();
235
+ }
236
+ const reader = response.body.getReader();
237
+ const decoder = new TextDecoder();
238
+ let buffer = "";
239
+ while (!abortController.signal.aborted) {
240
+ const { done, value } = await reader.read();
241
+ if (done) break;
242
+ this.bumpHeartbeatTimeout(abortController);
243
+ buffer += decoder.decode(value, { stream: true });
244
+ const blocks = buffer.split(/\r?\n\r?\n/);
245
+ buffer = blocks.pop() ?? "";
246
+ for (const block of blocks) {
247
+ const parsedEvent = parseSseEventBlock(block);
248
+ if (!parsedEvent) continue;
249
+ if (parsedEvent.retryMs !== void 0) {
250
+ this.streamRetryDelay = parsedEvent.retryMs;
251
+ }
252
+ this.handleStreamMessage(parsedEvent.event, parsedEvent.data);
253
+ }
254
+ }
255
+ buffer += decoder.decode();
256
+ if (buffer.trim()) {
257
+ const parsedEvent = parseSseEventBlock(buffer);
258
+ if (parsedEvent) {
259
+ if (parsedEvent.retryMs !== void 0) {
260
+ this.streamRetryDelay = parsedEvent.retryMs;
261
+ }
262
+ this.handleStreamMessage(parsedEvent.event, parsedEvent.data);
263
+ }
264
+ }
265
+ } catch (err) {
266
+ if (!isAbortError(err)) {
267
+ console.error("[canarygate] Stream connection failed:", err);
268
+ }
269
+ } finally {
270
+ this.clearHeartbeatTimeout();
271
+ if (this.streamAbortController === abortController) {
272
+ this.streamAbortController = null;
273
+ }
274
+ if (!this.destroyed) {
275
+ this.stale = true;
276
+ this.scheduleReconnect();
277
+ }
278
+ }
279
+ }
280
+ connectStream() {
281
+ if (this.destroyed || this.streamAbortController) return;
282
+ const abortController = new AbortController();
283
+ this.streamAbortController = abortController;
284
+ void this.consumeStream(abortController);
285
+ }
286
+ startPolling() {
287
+ if (this.destroyed || this.streamEnabled || this.pollIntervalMs <= 0 || this.pollTimeout) {
288
+ return;
289
+ }
290
+ this.pollTimeout = setInterval(() => {
291
+ void this.fetchFlags();
292
+ }, this.pollIntervalMs);
293
+ }
294
+ stopPolling() {
295
+ if (this.pollTimeout) {
296
+ clearInterval(this.pollTimeout);
297
+ this.pollTimeout = null;
298
+ }
299
+ }
300
+ getFlag(key, context) {
301
+ const raw = this.cache.get(key);
302
+ if (!raw) return void 0;
303
+ const evaluationId = context?.userId || this.anonId;
304
+ if (raw.type === "rollout") {
305
+ const inRollout = raw.enabled && hashString(`${raw.key}:${evaluationId}`) < raw.rolloutPercent;
306
+ return {
307
+ key: raw.key,
308
+ type: "rollout",
309
+ enabled: inRollout,
310
+ percent: raw.rolloutPercent
311
+ };
312
+ }
313
+ return { key: raw.key, type: "boolean", enabled: raw.enabled };
314
+ }
315
+ getFlags(context) {
316
+ return Array.from(this.cache.keys()).map(
317
+ (key) => this.getFlag(key, context)
318
+ );
319
+ }
320
+ isStale() {
321
+ return this.stale;
322
+ }
323
+ getLastSyncAt() {
324
+ return this.lastSyncAt;
325
+ }
326
+ disconnect() {
327
+ this.destroyed = true;
328
+ this.stopPolling();
329
+ if (this.reconnectTimeout) {
330
+ clearTimeout(this.reconnectTimeout);
331
+ this.reconnectTimeout = null;
332
+ }
333
+ this.clearHeartbeatTimeout();
334
+ this.streamAbortController?.abort();
335
+ this.streamAbortController = null;
336
+ }
337
+ };
338
+
339
+ export {
340
+ CanaryGateBase
341
+ };
package/dist/client.js CHANGED
@@ -70,7 +70,7 @@ var DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
70
70
  var DEFAULT_HEARTBEAT_TIMEOUT_MS = 65e3;
71
71
  var DEFAULT_POLL_INTERVAL_MS = 3e4;
72
72
  var MIN_POLL_INTERVAL_MS = 3e3;
73
- var DEFAULT_BASE_URL = "http://localhost:3001";
73
+ var DEFAULT_BASE_URL = "https://api.canarygate.dev";
74
74
  function resolveBaseUrl() {
75
75
  if (typeof process !== "undefined" && typeof process.env === "object" && process.env !== null) {
76
76
  return (process.env.CANARYGATE_BASE_URL ?? process.env.NEXT_PUBLIC_CANARYGATE_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
package/dist/client.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  CanaryGateBase
3
- } from "./chunk-X2JAB5XL.mjs";
3
+ } from "./chunk-LK2QXSGD.mjs";
4
4
 
5
5
  // src/client.ts
6
6
  var ANON_ID_KEY = "__cg_anon_id__";
package/dist/server.js CHANGED
@@ -70,7 +70,7 @@ var DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
70
70
  var DEFAULT_HEARTBEAT_TIMEOUT_MS = 65e3;
71
71
  var DEFAULT_POLL_INTERVAL_MS = 3e4;
72
72
  var MIN_POLL_INTERVAL_MS = 3e3;
73
- var DEFAULT_BASE_URL = "http://localhost:3001";
73
+ var DEFAULT_BASE_URL = "https://api.canarygate.dev";
74
74
  function resolveBaseUrl() {
75
75
  if (typeof process !== "undefined" && typeof process.env === "object" && process.env !== null) {
76
76
  return (process.env.CANARYGATE_BASE_URL ?? process.env.NEXT_PUBLIC_CANARYGATE_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
package/dist/server.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  CanaryGateBase
3
- } from "./chunk-X2JAB5XL.mjs";
3
+ } from "./chunk-LK2QXSGD.mjs";
4
4
 
5
5
  // src/server.ts
6
6
  var CanaryGate = class extends CanaryGateBase {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canarygate/sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "CanaryGate SDK — feature flags with real-time SSE streaming",
5
5
  "exports": {
6
6
  "./client": {