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